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,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user