add camera rig, click-to-move locomotion, and click interaction
Camera: Cinemachine-based rig with an anchor, a state director, point-of- interest framing, pan input, and dialogue framing volumes. Player: click-to-move over the NavMesh, a pointer input layer, and a motion arbiter that reconciles direct stick input with navigation-driven movement. The pure math is isolated in NightclubArcadia.Locomotion.Math with an EditMode suite, so the arbitration rules are testable without a scene. Interaction: IClickInteractable plus a bridge that routes pointer clicks to it. Supporting project settings: adds the Cinemachine package, the Interactable and Ground tags the raycasts depend on, and NavMesh agent dimensions matched to the player capsule (radius 0.3, height 1.8, climb 0.25). Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1647a101742545fe8af6187e3a743858
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,81 @@
|
||||
using Unity.Cinemachine;
|
||||
using UnityEngine;
|
||||
|
||||
namespace NightclubArcadia.Cinematics
|
||||
{
|
||||
/// <summary>
|
||||
/// Raises a reveal camera priority while the player is inside the volume.
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(BoxCollider))]
|
||||
public sealed class CameraFramingVolume : MonoBehaviour
|
||||
{
|
||||
[SerializeField] CinemachineCamera revealCamera;
|
||||
[SerializeField] int activePriority = 20;
|
||||
[SerializeField] float minDwell = 0.5f;
|
||||
|
||||
CameraStateDirector stateDirector;
|
||||
int defaultPriority;
|
||||
int occupants;
|
||||
float exitTimer;
|
||||
|
||||
void Awake()
|
||||
{
|
||||
var box = GetComponent<BoxCollider>();
|
||||
box.isTrigger = true;
|
||||
|
||||
if (revealCamera != null)
|
||||
{
|
||||
defaultPriority = revealCamera.Priority.Value;
|
||||
}
|
||||
|
||||
stateDirector = FindFirstObjectByType<CameraStateDirector>();
|
||||
}
|
||||
|
||||
void OnTriggerEnter(Collider other)
|
||||
{
|
||||
if (!other.CompareTag("Player") || revealCamera == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
occupants++;
|
||||
exitTimer = 0f;
|
||||
revealCamera.Priority.Value = activePriority;
|
||||
stateDirector?.RegisterReveal();
|
||||
}
|
||||
|
||||
void OnTriggerExit(Collider other)
|
||||
{
|
||||
if (!other.CompareTag("Player") || revealCamera == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
occupants = Mathf.Max(0, occupants - 1);
|
||||
if (occupants == 0)
|
||||
{
|
||||
exitTimer = minDwell;
|
||||
}
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (occupants > 0 || revealCamera == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (exitTimer > 0f)
|
||||
{
|
||||
exitTimer -= Time.deltaTime;
|
||||
return;
|
||||
}
|
||||
|
||||
if (revealCamera.Priority.Value != defaultPriority)
|
||||
{
|
||||
revealCamera.Priority.Value = defaultPriority;
|
||||
stateDirector?.UnregisterReveal();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7cccc93b78eb84019b4524d65305612a
|
||||
@@ -0,0 +1,162 @@
|
||||
using NightclubArcadia.Player;
|
||||
using NightclubArcadia.Player.Math;
|
||||
using UnityEngine;
|
||||
|
||||
namespace NightclubArcadia.Cinematics
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts mouse drag-pan into CameraRigAnchor manual offset with recenter rules.
|
||||
/// </summary>
|
||||
public sealed class CameraPanInput : MonoBehaviour
|
||||
{
|
||||
[SerializeField] Camera brainCamera;
|
||||
[SerializeField] CameraRigAnchor anchor;
|
||||
[SerializeField] CameraStateDirector stateDirector;
|
||||
[SerializeField] PlayerPointerInput pointerInput;
|
||||
[SerializeField] float panMetresPerPixel = 0.02f;
|
||||
[SerializeField] float maxPanDistance = 12f;
|
||||
[SerializeField] float recenterDelay = 2.5f;
|
||||
[SerializeField] float recenterSmoothTime = 0.8f;
|
||||
[SerializeField] float movementRecenterDelay = 0.4f;
|
||||
|
||||
float releaseTimer = float.MaxValue;
|
||||
Vector3 recenterVelocity;
|
||||
Vector3 camRight;
|
||||
Vector3 camForward;
|
||||
bool basisInitialized;
|
||||
|
||||
public bool IsManualPanActive => pointerInput != null && pointerInput.IsPanning || releaseTimer < recenterDelay;
|
||||
|
||||
void Awake()
|
||||
{
|
||||
if (anchor == null)
|
||||
{
|
||||
anchor = GetComponent<CameraRigAnchor>();
|
||||
}
|
||||
|
||||
if (stateDirector == null)
|
||||
{
|
||||
stateDirector = GetComponent<CameraStateDirector>();
|
||||
}
|
||||
|
||||
if (brainCamera == null)
|
||||
{
|
||||
brainCamera = Camera.main;
|
||||
}
|
||||
}
|
||||
|
||||
void Start()
|
||||
{
|
||||
if (pointerInput == null)
|
||||
{
|
||||
var player = GameObject.FindGameObjectWithTag("Player");
|
||||
if (player != null)
|
||||
{
|
||||
pointerInput = player.GetComponent<PlayerPointerInput>();
|
||||
}
|
||||
}
|
||||
|
||||
if (pointerInput != null)
|
||||
{
|
||||
pointerInput.PanDelta += OnPanDelta;
|
||||
pointerInput.PanEnded += OnPanEnded;
|
||||
}
|
||||
}
|
||||
|
||||
void OnDestroy()
|
||||
{
|
||||
if (pointerInput != null)
|
||||
{
|
||||
pointerInput.PanDelta -= OnPanDelta;
|
||||
pointerInput.PanEnded -= OnPanEnded;
|
||||
}
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (anchor == null || stateDirector == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (stateDirector.AllowsManualPan && pointerInput != null && pointerInput.IsPanning)
|
||||
{
|
||||
releaseTimer = 0f;
|
||||
}
|
||||
else if (releaseTimer < recenterDelay)
|
||||
{
|
||||
releaseTimer += Time.deltaTime;
|
||||
}
|
||||
|
||||
if (ShouldShortenRecenter())
|
||||
{
|
||||
releaseTimer = Mathf.Max(releaseTimer, recenterDelay - movementRecenterDelay);
|
||||
}
|
||||
|
||||
if (!stateDirector.AllowsManualPan)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (releaseTimer >= recenterDelay && (pointerInput == null || !pointerInput.IsPanning))
|
||||
{
|
||||
anchor.ManualPanOffset = Vector3.SmoothDamp(
|
||||
anchor.ManualPanOffset,
|
||||
Vector3.zero,
|
||||
ref recenterVelocity,
|
||||
recenterSmoothTime);
|
||||
}
|
||||
|
||||
anchor.ClampManualPan(maxPanDistance);
|
||||
}
|
||||
|
||||
void OnPanDelta(Vector2 delta)
|
||||
{
|
||||
if (stateDirector == null || !stateDirector.AllowsManualPan || anchor == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
EnsureCameraBasis();
|
||||
anchor.Nudge(MotionInputMath.ScreenDeltaToWorldPan(delta, camRight, camForward, panMetresPerPixel));
|
||||
anchor.ClampManualPan(maxPanDistance);
|
||||
}
|
||||
|
||||
void OnPanEnded()
|
||||
{
|
||||
releaseTimer = 0f;
|
||||
}
|
||||
|
||||
bool ShouldShortenRecenter()
|
||||
{
|
||||
var player = GameObject.FindGameObjectWithTag("Player");
|
||||
if (player == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var arbiter = player.GetComponent<PlayerMotionArbiter>();
|
||||
return arbiter != null && arbiter.ActiveSource != MotionSource.None;
|
||||
}
|
||||
|
||||
void EnsureCameraBasis()
|
||||
{
|
||||
if (brainCamera == null)
|
||||
{
|
||||
brainCamera = Camera.main;
|
||||
}
|
||||
|
||||
if (brainCamera == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!basisInitialized)
|
||||
{
|
||||
camRight = brainCamera.transform.right;
|
||||
camForward = brainCamera.transform.forward;
|
||||
basisInitialized = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f9b06ade88f2643a3b9f52a80e06081d
|
||||
@@ -0,0 +1,31 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace NightclubArcadia.Cinematics
|
||||
{
|
||||
/// <summary>
|
||||
/// Marker on NPCs and props that the POI director may bias toward.
|
||||
/// </summary>
|
||||
public sealed class CameraPointOfInterest : MonoBehaviour
|
||||
{
|
||||
[SerializeField] float weight = 1f;
|
||||
[SerializeField] float triggerRadius = 8f;
|
||||
[SerializeField] float maxBias = 2.5f;
|
||||
[SerializeField] float extraDistance = 1.5f;
|
||||
|
||||
public float Weight => weight;
|
||||
public float TriggerRadius => triggerRadius;
|
||||
public float MaxBias => maxBias;
|
||||
public float ExtraDistance => extraDistance;
|
||||
public Transform Target => transform;
|
||||
|
||||
void OnEnable()
|
||||
{
|
||||
CameraPointOfInterestDirector.Register(this);
|
||||
}
|
||||
|
||||
void OnDisable()
|
||||
{
|
||||
CameraPointOfInterestDirector.Unregister(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7862f80331f384d28b27600075971a77
|
||||
@@ -0,0 +1,161 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace NightclubArcadia.Cinematics
|
||||
{
|
||||
/// <summary>
|
||||
/// Scores registered POIs and writes bias onto the camera anchor.
|
||||
/// </summary>
|
||||
public sealed class CameraPointOfInterestDirector : MonoBehaviour
|
||||
{
|
||||
static readonly List<CameraPointOfInterest> Registered = new List<CameraPointOfInterest>();
|
||||
|
||||
[SerializeField] CameraRigAnchor anchor;
|
||||
[SerializeField] CameraStateDirector stateDirector;
|
||||
[SerializeField] float poiSmoothTime = 1f;
|
||||
[SerializeField] float poiReleaseTime = 1.4f;
|
||||
[SerializeField] float maxHeadingAngle = 100f;
|
||||
|
||||
Vector3 poiVelocity;
|
||||
float distanceVelocity;
|
||||
CameraPointOfInterest activePoi;
|
||||
|
||||
public bool HasActivePoi => activePoi != null;
|
||||
|
||||
public static void Register(CameraPointOfInterest poi)
|
||||
{
|
||||
if (poi != null && !Registered.Contains(poi))
|
||||
{
|
||||
Registered.Add(poi);
|
||||
}
|
||||
}
|
||||
|
||||
public static void Unregister(CameraPointOfInterest poi)
|
||||
{
|
||||
Registered.Remove(poi);
|
||||
}
|
||||
|
||||
void Awake()
|
||||
{
|
||||
if (anchor == null)
|
||||
{
|
||||
anchor = GetComponent<CameraRigAnchor>();
|
||||
}
|
||||
|
||||
if (stateDirector == null)
|
||||
{
|
||||
stateDirector = GetComponent<CameraStateDirector>();
|
||||
}
|
||||
}
|
||||
|
||||
void LateUpdate()
|
||||
{
|
||||
if (anchor == null || stateDirector == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var follow = anchor.FollowTarget;
|
||||
if (follow == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stateDirector.AllowsPointOfInterest)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (stateDirector.Current == CameraState.ManualPan)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
activePoi = FindBestPoi(follow);
|
||||
if (activePoi == null)
|
||||
{
|
||||
ReleasePoi();
|
||||
return;
|
||||
}
|
||||
|
||||
var toPoi = activePoi.Target.position - follow.position;
|
||||
toPoi.y = 0f;
|
||||
var targetBias = Vector3.ClampMagnitude(toPoi, activePoi.MaxBias);
|
||||
|
||||
anchor.PoiBias = Vector3.SmoothDamp(
|
||||
anchor.PoiBias,
|
||||
targetBias,
|
||||
ref poiVelocity,
|
||||
poiSmoothTime);
|
||||
|
||||
anchor.DistanceBias = Mathf.SmoothDamp(
|
||||
anchor.DistanceBias,
|
||||
activePoi.ExtraDistance,
|
||||
ref distanceVelocity,
|
||||
poiSmoothTime);
|
||||
}
|
||||
|
||||
CameraPointOfInterest FindBestPoi(Transform follow)
|
||||
{
|
||||
CameraPointOfInterest best = null;
|
||||
var bestScore = 0f;
|
||||
var forward = follow.forward;
|
||||
|
||||
foreach (var poi in Registered)
|
||||
{
|
||||
if (poi == null || !poi.isActiveAndEnabled)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var offset = poi.Target.position - follow.position;
|
||||
offset.y = 0f;
|
||||
var distance = offset.magnitude;
|
||||
if (distance > poi.TriggerRadius || distance <= 0.01f)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var angle = Vector3.Angle(forward, offset);
|
||||
if (angle > maxHeadingAngle)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var score = poi.Weight * (1f - distance / poi.TriggerRadius);
|
||||
if (score > bestScore)
|
||||
{
|
||||
bestScore = score;
|
||||
best = poi;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
void ReleasePoi()
|
||||
{
|
||||
if (anchor == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
anchor.PoiBias = Vector3.SmoothDamp(
|
||||
anchor.PoiBias,
|
||||
Vector3.zero,
|
||||
ref poiVelocity,
|
||||
poiReleaseTime);
|
||||
|
||||
anchor.DistanceBias = Mathf.SmoothDamp(
|
||||
anchor.DistanceBias,
|
||||
0f,
|
||||
ref distanceVelocity,
|
||||
poiReleaseTime);
|
||||
|
||||
if (anchor.PoiBias.sqrMagnitude < 0.0001f && Mathf.Abs(anchor.DistanceBias) < 0.01f)
|
||||
{
|
||||
activePoi = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e3343417e1de440f095b73e55c6977b6
|
||||
@@ -0,0 +1,85 @@
|
||||
using Unity.Cinemachine;
|
||||
using UnityEngine;
|
||||
|
||||
namespace NightclubArcadia.Cinematics
|
||||
{
|
||||
/// <summary>
|
||||
/// Proxy transform the follow camera tracks. Combines player follow,
|
||||
/// POI bias, manual pan offset, and distance bias.
|
||||
/// </summary>
|
||||
public sealed class CameraRigAnchor : MonoBehaviour
|
||||
{
|
||||
[SerializeField] Transform followTarget;
|
||||
[SerializeField] CinemachinePositionComposer composer;
|
||||
[SerializeField] float followSmoothTime = 0.12f;
|
||||
[SerializeField] float distanceSmoothTime = 0.8f;
|
||||
|
||||
float defaultCameraDistance = 10f;
|
||||
float distanceVelocity;
|
||||
Vector3 followVelocity;
|
||||
|
||||
public Vector3 PoiBias { get; set; }
|
||||
public Vector3 ManualPanOffset { get; set; }
|
||||
public float DistanceBias { get; set; }
|
||||
|
||||
public Transform FollowTarget => followTarget;
|
||||
|
||||
void Start()
|
||||
{
|
||||
if (composer == null)
|
||||
{
|
||||
composer = FindFirstObjectByType<CinemachinePositionComposer>();
|
||||
}
|
||||
|
||||
if (composer != null)
|
||||
{
|
||||
defaultCameraDistance = composer.CameraDistance;
|
||||
}
|
||||
}
|
||||
|
||||
void LateUpdate()
|
||||
{
|
||||
if (followTarget == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var targetPosition = followTarget.position + PoiBias + ManualPanOffset;
|
||||
transform.position = Vector3.SmoothDamp(
|
||||
transform.position,
|
||||
targetPosition,
|
||||
ref followVelocity,
|
||||
followSmoothTime);
|
||||
|
||||
if (composer != null)
|
||||
{
|
||||
var targetDistance = defaultCameraDistance + DistanceBias;
|
||||
composer.CameraDistance = Mathf.SmoothDamp(
|
||||
composer.CameraDistance,
|
||||
targetDistance,
|
||||
ref distanceVelocity,
|
||||
distanceSmoothTime);
|
||||
}
|
||||
}
|
||||
|
||||
public void Nudge(Vector3 worldDelta)
|
||||
{
|
||||
ManualPanOffset += worldDelta;
|
||||
}
|
||||
|
||||
public void ClampManualPan(float maxDistance)
|
||||
{
|
||||
if (followTarget == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var offset = ManualPanOffset;
|
||||
offset.y = 0f;
|
||||
if (offset.magnitude > maxDistance)
|
||||
{
|
||||
ManualPanOffset = offset.normalized * maxDistance;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 16d6717a3703a4bb3a8c3bb0bb0392de
|
||||
@@ -0,0 +1,135 @@
|
||||
using NightclubArcadia.UI;
|
||||
using UnityEngine;
|
||||
|
||||
namespace NightclubArcadia.Cinematics
|
||||
{
|
||||
public enum CameraState
|
||||
{
|
||||
Follow,
|
||||
ManualPan,
|
||||
PointOfInterest,
|
||||
Reveal,
|
||||
Dialogue,
|
||||
Menu
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves which camera subsystem may write each frame.
|
||||
/// </summary>
|
||||
public sealed class CameraStateDirector : MonoBehaviour
|
||||
{
|
||||
[SerializeField] CameraRigAnchor anchor;
|
||||
[SerializeField] float dialogueRecenterTime = 0.5f;
|
||||
|
||||
int activeRevealCount;
|
||||
float dialogueRecenterTimer;
|
||||
bool wasDialogueActive;
|
||||
bool wasMenuOpen;
|
||||
|
||||
public CameraState Current { get; private set; } = CameraState.Follow;
|
||||
|
||||
public bool AllowsManualPan =>
|
||||
Current != CameraState.Dialogue
|
||||
&& Current != CameraState.Menu
|
||||
&& Current != CameraState.Reveal;
|
||||
|
||||
public bool AllowsPointOfInterest =>
|
||||
Current == CameraState.Follow || Current == CameraState.PointOfInterest;
|
||||
|
||||
void Awake()
|
||||
{
|
||||
if (anchor == null)
|
||||
{
|
||||
anchor = GetComponent<CameraRigAnchor>();
|
||||
}
|
||||
}
|
||||
|
||||
void Start()
|
||||
{
|
||||
if (UIStateController.Instance != null)
|
||||
{
|
||||
UIStateController.Instance.StateChanged += OnUiStateChanged;
|
||||
}
|
||||
|
||||
ResolveState(false);
|
||||
}
|
||||
|
||||
void OnDestroy()
|
||||
{
|
||||
if (UIStateController.Instance != null)
|
||||
{
|
||||
UIStateController.Instance.StateChanged -= OnUiStateChanged;
|
||||
}
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
ResolveState(true);
|
||||
|
||||
if (Current == CameraState.Dialogue && anchor != null)
|
||||
{
|
||||
dialogueRecenterTimer += Time.deltaTime;
|
||||
var t = Mathf.Clamp01(dialogueRecenterTimer / dialogueRecenterTime);
|
||||
anchor.ManualPanOffset = Vector3.Lerp(anchor.ManualPanOffset, Vector3.zero, t);
|
||||
}
|
||||
}
|
||||
|
||||
void OnUiStateChanged() => ResolveState(true);
|
||||
|
||||
public void RegisterReveal() => activeRevealCount++;
|
||||
|
||||
public void UnregisterReveal() => activeRevealCount = Mathf.Max(0, activeRevealCount - 1);
|
||||
|
||||
void ResolveState(bool allowManualPanPromotion)
|
||||
{
|
||||
var ui = UIStateController.Instance;
|
||||
var menuOpen = ui != null && ui.IsMenuOpen;
|
||||
var dialogueActive = ui != null && ui.IsDialogueActive;
|
||||
|
||||
if (menuOpen)
|
||||
{
|
||||
Current = CameraState.Menu;
|
||||
}
|
||||
else if (dialogueActive)
|
||||
{
|
||||
if (!wasDialogueActive)
|
||||
{
|
||||
dialogueRecenterTimer = 0f;
|
||||
}
|
||||
|
||||
Current = CameraState.Dialogue;
|
||||
}
|
||||
else if (activeRevealCount > 0)
|
||||
{
|
||||
Current = CameraState.Reveal;
|
||||
}
|
||||
else if (allowManualPanPromotion && IsManualPanActive())
|
||||
{
|
||||
Current = CameraState.ManualPan;
|
||||
}
|
||||
else if (HasActivePointOfInterest())
|
||||
{
|
||||
Current = CameraState.PointOfInterest;
|
||||
}
|
||||
else
|
||||
{
|
||||
Current = CameraState.Follow;
|
||||
}
|
||||
|
||||
wasDialogueActive = dialogueActive;
|
||||
wasMenuOpen = menuOpen;
|
||||
}
|
||||
|
||||
bool IsManualPanActive()
|
||||
{
|
||||
var pan = GetComponent<CameraPanInput>();
|
||||
return pan != null && pan.IsManualPanActive;
|
||||
}
|
||||
|
||||
bool HasActivePointOfInterest()
|
||||
{
|
||||
var director = GetComponent<CameraPointOfInterestDirector>();
|
||||
return director != null && director.HasActivePoi;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ca0160e4d996040238dab18725a2e844
|
||||
@@ -0,0 +1,72 @@
|
||||
using NightclubArcadia.UI;
|
||||
using Unity.Cinemachine;
|
||||
using UnityEngine;
|
||||
|
||||
namespace NightclubArcadia.Cinematics
|
||||
{
|
||||
/// <summary>
|
||||
/// Shifts CinemachinePositionComposer.Composition.ScreenPosition so the
|
||||
/// player is centred in the screen space left of the dialogue panel.
|
||||
/// </summary>
|
||||
public sealed class DialogueCameraFraming : MonoBehaviour
|
||||
{
|
||||
[SerializeField] CinemachinePositionComposer composer;
|
||||
[SerializeField] float dialogueScreenX = -0.17f;
|
||||
[SerializeField] bool suppressDeadZoneXInDialogue = true;
|
||||
|
||||
float defaultDeadZoneX = 0.2f;
|
||||
|
||||
void Start()
|
||||
{
|
||||
if (composer == null)
|
||||
{
|
||||
composer = FindFirstObjectByType<CinemachinePositionComposer>();
|
||||
}
|
||||
|
||||
if (composer != null)
|
||||
{
|
||||
defaultDeadZoneX = composer.Composition.DeadZone.Size.x;
|
||||
}
|
||||
|
||||
if (UIStateController.Instance != null)
|
||||
{
|
||||
UIStateController.Instance.StateChanged += OnStateChanged;
|
||||
}
|
||||
|
||||
ApplyFraming();
|
||||
}
|
||||
|
||||
void OnDestroy()
|
||||
{
|
||||
if (UIStateController.Instance != null)
|
||||
{
|
||||
UIStateController.Instance.StateChanged -= OnStateChanged;
|
||||
}
|
||||
}
|
||||
|
||||
void OnStateChanged() => ApplyFraming();
|
||||
|
||||
void ApplyFraming()
|
||||
{
|
||||
if (composer == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var active = UIStateController.Instance != null && UIStateController.Instance.IsDialogueActive;
|
||||
var target = active ? dialogueScreenX : 0f;
|
||||
|
||||
var composition = composer.Composition;
|
||||
composition.ScreenPosition = new Vector2(target, composition.ScreenPosition.y);
|
||||
|
||||
if (suppressDeadZoneXInDialogue)
|
||||
{
|
||||
var size = composition.DeadZone.Size;
|
||||
size.x = active ? 0f : defaultDeadZoneX;
|
||||
composition.DeadZone.Size = size;
|
||||
}
|
||||
|
||||
composer.Composition = composition;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 56142a5f8795401498df8e01dc74b258
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 97642050992484049b6c021481f2baf2
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,29 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace NightclubArcadia.Interaction
|
||||
{
|
||||
/// <summary>
|
||||
/// Adapts NPCStandIn to the click-to-interact pipeline without modifying
|
||||
/// the existing proximity + keypress path.
|
||||
/// </summary>
|
||||
public sealed class ClickInteractableBridge : MonoBehaviour, IClickInteractable
|
||||
{
|
||||
[SerializeField] NPCStandIn npc;
|
||||
|
||||
public Transform ApproachPoint => transform;
|
||||
public float ApproachRadius => npc != null ? Mathf.Max(0.15f, npc.interactionRange - 0.5f) : 0.15f;
|
||||
|
||||
void Awake()
|
||||
{
|
||||
if (npc == null)
|
||||
{
|
||||
npc = GetComponent<NPCStandIn>();
|
||||
}
|
||||
}
|
||||
|
||||
public void OnApproached()
|
||||
{
|
||||
npc?.Interact();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5257f07d188de44eb9a04ba4d3a65fec
|
||||
@@ -0,0 +1,11 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace NightclubArcadia.Interaction
|
||||
{
|
||||
public interface IClickInteractable
|
||||
{
|
||||
Transform ApproachPoint { get; }
|
||||
float ApproachRadius { get; }
|
||||
void OnApproached();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4812c7fb9cf2d41c28966640827dd0c8
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 381786de30b3e49e7a693028cc2127f5
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,234 @@
|
||||
using System;
|
||||
using NightclubArcadia.Interaction;
|
||||
using NightclubArcadia.Player.Math;
|
||||
using StarterAssets;
|
||||
using UnityEngine;
|
||||
using UnityEngine.AI;
|
||||
|
||||
namespace NightclubArcadia.Player
|
||||
{
|
||||
/// <summary>
|
||||
/// NavMeshAgent in oracle mode: computes desired move input for the arbiter
|
||||
/// without moving the CharacterController directly.
|
||||
/// </summary>
|
||||
[DefaultExecutionOrder(-60)]
|
||||
[RequireComponent(typeof(NavMeshAgent))]
|
||||
public sealed class ClickToMoveController : MonoBehaviour
|
||||
{
|
||||
const float SampleRadius = 1.5f;
|
||||
const float StallWindow = 0.4f;
|
||||
const float StallDistance = 0.05f;
|
||||
|
||||
[SerializeField] Camera brainCamera;
|
||||
|
||||
NavMeshAgent agent;
|
||||
ThirdPersonController locomotion;
|
||||
IClickInteractable pendingInteractable;
|
||||
|
||||
bool forceRunForPath;
|
||||
[SerializeField] float runDistanceThreshold = 6f;
|
||||
Vector3 lastPosition;
|
||||
float stallTimer;
|
||||
bool hasPath;
|
||||
|
||||
public bool HasPath => hasPath;
|
||||
public Vector3 Destination { get; private set; }
|
||||
public Vector2 DesiredMoveInput { get; private set; }
|
||||
public float RemainingDistance => agent != null ? agent.remainingDistance : 0f;
|
||||
public bool WantsSprint => hasPath && (forceRunForPath || agent.remainingDistance > runDistanceThreshold);
|
||||
|
||||
public event Action<bool> PathFinished;
|
||||
public event Action RunRequested;
|
||||
|
||||
void Awake()
|
||||
{
|
||||
agent = GetComponent<NavMeshAgent>();
|
||||
locomotion = GetComponent<ThirdPersonController>();
|
||||
|
||||
agent.updatePosition = false;
|
||||
agent.updateRotation = false;
|
||||
agent.updateUpAxis = false;
|
||||
agent.angularSpeed = 0f;
|
||||
agent.acceleration = 1000f;
|
||||
agent.autoBraking = true;
|
||||
agent.stoppingDistance = 0.15f;
|
||||
agent.radius = 0.3f;
|
||||
agent.height = 1.8f;
|
||||
|
||||
if (brainCamera == null)
|
||||
{
|
||||
brainCamera = Camera.main;
|
||||
}
|
||||
|
||||
SyncAgentSpeed(false);
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (!hasPath)
|
||||
{
|
||||
DesiredMoveInput = Vector2.zero;
|
||||
return;
|
||||
}
|
||||
|
||||
SyncAgentSpeed(forceRunForPath || agent.remainingDistance > runDistanceThreshold);
|
||||
|
||||
if (brainCamera == null)
|
||||
{
|
||||
brainCamera = Camera.main;
|
||||
}
|
||||
|
||||
var cameraYaw = brainCamera != null ? brainCamera.transform.eulerAngles.y : 0f;
|
||||
DesiredMoveInput = MotionInputMath.WorldToCameraRelativeInput(
|
||||
agent.desiredVelocity,
|
||||
agent.speed,
|
||||
cameraYaw);
|
||||
|
||||
CheckStall();
|
||||
CheckArrival();
|
||||
}
|
||||
|
||||
void LateUpdate()
|
||||
{
|
||||
if (agent != null)
|
||||
{
|
||||
agent.nextPosition = transform.position;
|
||||
}
|
||||
}
|
||||
|
||||
public void RequestRun()
|
||||
{
|
||||
forceRunForPath = true;
|
||||
RunRequested?.Invoke();
|
||||
}
|
||||
|
||||
public bool SetDestination(Vector3 worldPoint)
|
||||
{
|
||||
pendingInteractable = null;
|
||||
|
||||
if (!TrySampleNavMesh(worldPoint, out var hit))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
agent.stoppingDistance = 0.15f;
|
||||
agent.isStopped = false;
|
||||
agent.SetDestination(hit.position);
|
||||
Destination = hit.position;
|
||||
hasPath = true;
|
||||
ResetStallTracking();
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool SetInteractionTarget(IClickInteractable interactable)
|
||||
{
|
||||
if (interactable == null || interactable.ApproachPoint == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
pendingInteractable = interactable;
|
||||
var approach = interactable.ApproachPoint.position;
|
||||
|
||||
if (!TrySampleNavMesh(approach, out var hit))
|
||||
{
|
||||
pendingInteractable = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
agent.stoppingDistance = Mathf.Max(0.15f, interactable.ApproachRadius);
|
||||
agent.isStopped = false;
|
||||
agent.SetDestination(hit.position);
|
||||
Destination = hit.position;
|
||||
hasPath = true;
|
||||
ResetStallTracking();
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Cancel()
|
||||
{
|
||||
if (agent != null)
|
||||
{
|
||||
agent.ResetPath();
|
||||
agent.isStopped = true;
|
||||
}
|
||||
|
||||
hasPath = false;
|
||||
forceRunForPath = false;
|
||||
pendingInteractable = null;
|
||||
DesiredMoveInput = Vector2.zero;
|
||||
ResetStallTracking();
|
||||
}
|
||||
|
||||
bool TrySampleNavMesh(Vector3 point, out NavMeshHit hit)
|
||||
{
|
||||
return NavMesh.SamplePosition(point, out hit, SampleRadius, NavMesh.AllAreas);
|
||||
}
|
||||
|
||||
void CheckArrival()
|
||||
{
|
||||
if (!hasPath || agent.pathPending)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (agent.remainingDistance > agent.stoppingDistance)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var success = agent.pathStatus != NavMeshPathStatus.PathInvalid;
|
||||
hasPath = false;
|
||||
agent.ResetPath();
|
||||
|
||||
var interactable = pendingInteractable;
|
||||
pendingInteractable = null;
|
||||
|
||||
PathFinished?.Invoke(success);
|
||||
|
||||
if (success && interactable != null)
|
||||
{
|
||||
interactable.OnApproached();
|
||||
}
|
||||
}
|
||||
|
||||
void CheckStall()
|
||||
{
|
||||
var displacement = Vector3.Distance(
|
||||
new Vector3(transform.position.x, 0f, transform.position.z),
|
||||
new Vector3(lastPosition.x, 0f, lastPosition.z));
|
||||
|
||||
if (displacement < StallDistance)
|
||||
{
|
||||
stallTimer += Time.deltaTime;
|
||||
}
|
||||
else
|
||||
{
|
||||
stallTimer = 0f;
|
||||
lastPosition = transform.position;
|
||||
}
|
||||
|
||||
if (stallTimer >= StallWindow && agent.remainingDistance > agent.stoppingDistance)
|
||||
{
|
||||
Cancel();
|
||||
PathFinished?.Invoke(false);
|
||||
}
|
||||
}
|
||||
|
||||
void ResetStallTracking()
|
||||
{
|
||||
lastPosition = transform.position;
|
||||
stallTimer = 0f;
|
||||
}
|
||||
|
||||
void SyncAgentSpeed(bool sprint)
|
||||
{
|
||||
if (locomotion == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
agent.speed = sprint ? locomotion.SprintSpeed : locomotion.MoveSpeed;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c2762b30721644611b9672bd4781cf15
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c32faf711a4044ed88ff1be2790915f7
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,42 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace NightclubArcadia.Player.Math
|
||||
{
|
||||
/// <summary>
|
||||
/// Pure math helpers shared by click-to-move and camera pan.
|
||||
/// </summary>
|
||||
public static class MotionInputMath
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts a NavMeshAgent desired velocity into the camera-relative
|
||||
/// Vector2 that ThirdPersonController expects on StarterAssetsInputs.move.
|
||||
/// </summary>
|
||||
public static Vector2 WorldToCameraRelativeInput(Vector3 worldVelocity, float agentSpeed, float cameraYaw)
|
||||
{
|
||||
if (worldVelocity.sqrMagnitude < 0.0001f || agentSpeed <= 0f)
|
||||
{
|
||||
return Vector2.zero;
|
||||
}
|
||||
|
||||
var horizontal = new Vector3(worldVelocity.x, 0f, worldVelocity.z);
|
||||
var magnitude = horizontal.magnitude;
|
||||
var fraction = Mathf.Clamp01(magnitude / agentSpeed);
|
||||
var local = Quaternion.Euler(0f, -cameraYaw, 0f) * horizontal.normalized;
|
||||
return new Vector2(local.x, local.z) * fraction;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts screen-space pointer delta into a world-plane pan offset.
|
||||
/// Drag direction is inverted so the world follows the cursor.
|
||||
/// </summary>
|
||||
public static Vector3 ScreenDeltaToWorldPan(
|
||||
Vector2 pixelDelta,
|
||||
Vector3 camRight,
|
||||
Vector3 camForward,
|
||||
float metresPerPixel)
|
||||
{
|
||||
var forward = Vector3.ProjectOnPlane(camForward, Vector3.up).normalized;
|
||||
return (-pixelDelta.x * camRight + -pixelDelta.y * forward) * metresPerPixel;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9af00d68e15e24c02811500cdf0dd2d4
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "NightclubArcadia.Locomotion.Math",
|
||||
"rootNamespace": "NightclubArcadia.Player.Math",
|
||||
"references": [],
|
||||
"includePlatforms": [],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": false,
|
||||
"precompiledReferences": [],
|
||||
"autoReferenced": true,
|
||||
"defineConstraints": [],
|
||||
"versionDefines": [],
|
||||
"noEngineReferences": false
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 12491a5edc87346d28ffc48f9ab6562c
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,114 @@
|
||||
using NightclubArcadia.Interaction;
|
||||
using NightclubArcadia.UI;
|
||||
using StarterAssets;
|
||||
using UnityEngine;
|
||||
#if ENABLE_INPUT_SYSTEM
|
||||
using UnityEngine.InputSystem;
|
||||
#endif
|
||||
|
||||
namespace NightclubArcadia.Player
|
||||
{
|
||||
public enum MotionSource
|
||||
{
|
||||
None,
|
||||
Direct,
|
||||
Navigation
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sole writer of StarterAssetsInputs move/sprint. Picks one input source
|
||||
/// per frame: direct keyboard/gamepad or click-to-move navigation.
|
||||
/// </summary>
|
||||
[DefaultExecutionOrder(-50)]
|
||||
public sealed class PlayerMotionArbiter : MonoBehaviour
|
||||
{
|
||||
[SerializeField] float directInputCancelThreshold = 0.15f;
|
||||
[SerializeField] float runDistanceThreshold = 6f;
|
||||
|
||||
StarterAssetsInputs playerInputs;
|
||||
PlayerControlLock controlLock;
|
||||
ClickToMoveController clickToMove;
|
||||
#if ENABLE_INPUT_SYSTEM
|
||||
PlayerInput playerInput;
|
||||
#endif
|
||||
|
||||
public MotionSource ActiveSource { get; private set; }
|
||||
|
||||
void Awake()
|
||||
{
|
||||
playerInputs = GetComponent<StarterAssetsInputs>();
|
||||
controlLock = GetComponent<PlayerControlLock>();
|
||||
clickToMove = GetComponent<ClickToMoveController>();
|
||||
#if ENABLE_INPUT_SYSTEM
|
||||
playerInput = GetComponent<PlayerInput>();
|
||||
#endif
|
||||
}
|
||||
|
||||
public void ResetState()
|
||||
{
|
||||
ActiveSource = MotionSource.None;
|
||||
clickToMove?.Cancel();
|
||||
WriteInput(Vector2.zero, false);
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (controlLock != null && controlLock.IsLocked)
|
||||
{
|
||||
clickToMove?.Cancel();
|
||||
ActiveSource = MotionSource.None;
|
||||
WriteInput(Vector2.zero, false);
|
||||
return;
|
||||
}
|
||||
|
||||
#if ENABLE_INPUT_SYSTEM
|
||||
var raw = playerInput.actions["Move"].ReadValue<Vector2>();
|
||||
#else
|
||||
var raw = Vector2.zero;
|
||||
#endif
|
||||
|
||||
if (raw.sqrMagnitude > directInputCancelThreshold * directInputCancelThreshold)
|
||||
{
|
||||
clickToMove?.Cancel();
|
||||
ActiveSource = MotionSource.Direct;
|
||||
WriteInput(raw, ReadDirectSprint());
|
||||
return;
|
||||
}
|
||||
|
||||
if (clickToMove != null && clickToMove.HasPath)
|
||||
{
|
||||
ActiveSource = MotionSource.Navigation;
|
||||
WriteInput(clickToMove.DesiredMoveInput, ShouldNavigationSprint());
|
||||
return;
|
||||
}
|
||||
|
||||
ActiveSource = MotionSource.None;
|
||||
WriteInput(Vector2.zero, false);
|
||||
}
|
||||
|
||||
bool ReadDirectSprint()
|
||||
{
|
||||
#if ENABLE_INPUT_SYSTEM
|
||||
return playerInput.actions["Sprint"].IsPressed();
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool ShouldNavigationSprint()
|
||||
{
|
||||
return clickToMove != null && clickToMove.WantsSprint;
|
||||
}
|
||||
|
||||
void WriteInput(Vector2 move, bool sprint)
|
||||
{
|
||||
if (playerInputs == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
playerInputs.MoveInput(move);
|
||||
playerInputs.SprintInput(sprint);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 91bf24256c6694185b4247a736593727
|
||||
@@ -0,0 +1,61 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
#if ENABLE_INPUT_SYSTEM
|
||||
using UnityEngine.InputSystem;
|
||||
#endif
|
||||
|
||||
namespace NightclubArcadia.Player
|
||||
{
|
||||
/// <summary>
|
||||
/// Receives pointer and camera-pan SendMessages from PlayerInput and
|
||||
/// exposes them as C# events for navigation and camera systems.
|
||||
/// </summary>
|
||||
[DefaultExecutionOrder(-100)]
|
||||
public sealed class PlayerPointerInput : MonoBehaviour
|
||||
{
|
||||
public Vector2 ScreenPosition { get; private set; }
|
||||
public bool IsPanning { get; private set; }
|
||||
|
||||
public event Action Clicked;
|
||||
public event Action PanStarted;
|
||||
public event Action<Vector2> PanDelta;
|
||||
public event Action PanEnded;
|
||||
|
||||
#if ENABLE_INPUT_SYSTEM
|
||||
void OnPoint(InputValue value)
|
||||
{
|
||||
ScreenPosition = value.Get<Vector2>();
|
||||
}
|
||||
|
||||
void OnClick(InputValue value)
|
||||
{
|
||||
if (value.isPressed)
|
||||
{
|
||||
Clicked?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
void OnCameraPan(InputValue value)
|
||||
{
|
||||
if (value.isPressed && !IsPanning)
|
||||
{
|
||||
IsPanning = true;
|
||||
PanStarted?.Invoke();
|
||||
}
|
||||
else if (!value.isPressed && IsPanning)
|
||||
{
|
||||
IsPanning = false;
|
||||
PanEnded?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
void OnPointerDelta(InputValue value)
|
||||
{
|
||||
if (IsPanning)
|
||||
{
|
||||
PanDelta?.Invoke(value.Get<Vector2>());
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ff77faf7e79154095ad57ad50df8c605
|
||||
@@ -0,0 +1,203 @@
|
||||
using System;
|
||||
using NightclubArcadia.Interaction;
|
||||
using NightclubArcadia.UI;
|
||||
using UnityEngine;
|
||||
using UnityEngine.EventSystems;
|
||||
|
||||
namespace NightclubArcadia.Player
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts pointer clicks into click-to-move destinations or interactable
|
||||
/// approach targets.
|
||||
/// </summary>
|
||||
public sealed class PointerNavigationInput : MonoBehaviour
|
||||
{
|
||||
const float DoubleClickWindow = 0.35f;
|
||||
const float DoubleClickPixelThreshold = 40f;
|
||||
|
||||
[SerializeField] Camera brainCamera;
|
||||
[SerializeField] LayerMask clickMask = ~0;
|
||||
[SerializeField] PlayerPointerInput pointerInput;
|
||||
[SerializeField] ClickToMoveController clickToMove;
|
||||
[SerializeField] PlayerMotionArbiter motionArbiter;
|
||||
|
||||
float lastClickTime = -1f;
|
||||
Vector2 lastClickPosition;
|
||||
bool pendingClick;
|
||||
Vector2 pendingClickPosition;
|
||||
|
||||
void Awake()
|
||||
{
|
||||
if (pointerInput == null)
|
||||
{
|
||||
pointerInput = GetComponent<PlayerPointerInput>();
|
||||
}
|
||||
|
||||
if (clickToMove == null)
|
||||
{
|
||||
clickToMove = GetComponent<ClickToMoveController>();
|
||||
}
|
||||
|
||||
if (motionArbiter == null)
|
||||
{
|
||||
motionArbiter = GetComponent<PlayerMotionArbiter>();
|
||||
}
|
||||
|
||||
if (brainCamera == null)
|
||||
{
|
||||
brainCamera = Camera.main;
|
||||
}
|
||||
|
||||
if (clickMask.value == ~0)
|
||||
{
|
||||
clickMask = LayerMask.GetMask("Default", "Ground", "Interactable");
|
||||
}
|
||||
}
|
||||
|
||||
void OnEnable()
|
||||
{
|
||||
if (pointerInput != null)
|
||||
{
|
||||
pointerInput.Clicked += OnClicked;
|
||||
}
|
||||
}
|
||||
|
||||
void OnDisable()
|
||||
{
|
||||
if (pointerInput != null)
|
||||
{
|
||||
pointerInput.Clicked -= OnClicked;
|
||||
}
|
||||
|
||||
pendingClick = false;
|
||||
}
|
||||
|
||||
// Input System callbacks fire during event processing, where
|
||||
// IsPointerOverGameObject() is invalid (queries last frame's UI).
|
||||
// Queue the click and resolve it in Update instead.
|
||||
void OnClicked()
|
||||
{
|
||||
if (pointerInput == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
pendingClick = true;
|
||||
pendingClickPosition = pointerInput.ScreenPosition;
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (!pendingClick)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
pendingClick = false;
|
||||
ProcessClick(pendingClickPosition);
|
||||
}
|
||||
|
||||
void ProcessClick(Vector2 screenPos)
|
||||
{
|
||||
if (!CanNavigate())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (EventSystem.current != null && EventSystem.current.IsPointerOverGameObject())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (brainCamera == null)
|
||||
{
|
||||
brainCamera = Camera.main;
|
||||
}
|
||||
|
||||
if (brainCamera == null || clickToMove == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var ray = brainCamera.ScreenPointToRay(screenPos);
|
||||
var hits = Physics.RaycastAll(ray, 200f, clickMask, QueryTriggerInteraction.Collide);
|
||||
Array.Sort(hits, (a, b) => a.distance.CompareTo(b.distance));
|
||||
|
||||
var isDoubleClick = IsDoubleClick(screenPos);
|
||||
lastClickTime = Time.unscaledTime;
|
||||
lastClickPosition = screenPos;
|
||||
|
||||
foreach (var hit in hits)
|
||||
{
|
||||
var interactable = FindInteractable(hit.collider);
|
||||
if (interactable != null)
|
||||
{
|
||||
if (isDoubleClick)
|
||||
{
|
||||
clickToMove.RequestRun();
|
||||
}
|
||||
|
||||
if (clickToMove.SetInteractionTarget(interactable))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var hit in hits)
|
||||
{
|
||||
if (isDoubleClick)
|
||||
{
|
||||
clickToMove.RequestRun();
|
||||
}
|
||||
|
||||
if (clickToMove.SetDestination(hit.point))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool CanNavigate()
|
||||
{
|
||||
if (GetComponent<PlayerControlLock>() is { IsLocked: true })
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (UIStateController.Instance == null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return !UIStateController.Instance.IsDialogueActive
|
||||
&& !UIStateController.Instance.IsMenuOpen;
|
||||
}
|
||||
|
||||
bool IsDoubleClick(Vector2 screenPos)
|
||||
{
|
||||
if (lastClickTime < 0f)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var withinTime = Time.unscaledTime - lastClickTime <= DoubleClickWindow;
|
||||
var withinDistance = Vector2.Distance(screenPos, lastClickPosition) <= DoubleClickPixelThreshold;
|
||||
return withinTime && withinDistance;
|
||||
}
|
||||
|
||||
static IClickInteractable FindInteractable(Collider collider)
|
||||
{
|
||||
var behaviours = collider.GetComponentsInParent<MonoBehaviour>();
|
||||
foreach (var behaviour in behaviours)
|
||||
{
|
||||
if (behaviour is IClickInteractable interactable)
|
||||
{
|
||||
return interactable;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5f62460e35fda4829bd6ffeffe633a7e
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4392b20735bef4228be2723270edbb9b
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f3e10736152304d63ab3fc46cfdb2873
|
||||
+24
@@ -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
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: caa98c93c07f34ac6aedf23ee4f45c2a
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user