Files
nightclub-arcadia/NightclubArcadia/Assets/Scripts/Player/ClickToMoveController.cs
T
lennartandClaude Opus 5 40b4547c71 split the scene into Bootstrap, Systems and Level
The game now starts from Bootstrap.unity, which additively loads Systems (the
dialogue runner, skills, UI layer, camera rig and the player) and then a level
(geometry, light, navmesh, reveal cameras). The level becomes the active scene so
new objects and lighting land there.

The player moved into Systems. It had been parented under Room_101 — under level
geometry — and it is persistent content, not level content. Moving it also
removes most of the cross-scene breakage on its own.

Unity nulls any serialized reference that crosses a scene boundary. Measured
rather than guessed: the pre-split scene was pulled from git and its null
references diffed against the split result. Exactly seven were lost — both NPCs'
dialogueRunner, dialogueUI and player, plus the reveal camera's tracking target.
A first naive audit reported 210, which turned out to be pre-existing Unity
defaults like Image.m_Material; the baseline diff is what separated the two.

Each of the seven now resolves at runtime. SceneServices.Resolve fills in a null
Inspector reference by searching the loaded scenes, keeping an assigned value if
there is one; CinemachineFollowsPlayer binds the reveal camera once the player
exists. Both follow the pattern already used by CameraFramingVolume and by
NPCStandIn's player lookup, rather than introducing a new one.

Assets/Tests/PlayMode is new, and it immediately paid for itself. BootstrapTests
loads Bootstrap and asserts the whole game comes up; on its first run it caught a
real bug the split had introduced. The player's NavMeshAgent lives in Systems
while the NavMesh is baked into the level, so during load the agent exists
off-mesh and ResetPath logs an error. ClickToMoveController now guards on
agent.isOnNavMesh rather than a bare null check. No EditMode test could have seen
that, because none of it has run yet.

Two of those checks reach their types by name through reflection: NPCStandIn and
the Cinematics namespace live in Assembly-CSharp, and an asmdef test assembly
cannot reference the predefined assemblies. Per-area asmdefs remove the need.

Build settings list all three scenes with Bootstrap at index 0, which
LoadSceneAsync by name requires.

EditMode 42/42, PlayMode 5/5, YarnCheck 9 files / 35 nodes.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-25 20:50:25 +02:00

251 lines
7.1 KiB
C#

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;
}
if (!agent.isOnNavMesh)
{
pendingInteractable = null;
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;
}
if (!agent.isOnNavMesh)
{
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()
{
// isOnNavMesh, not just a null check: the agent lives in the Systems scene
// and the NavMesh is baked into the level, so between those two scenes
// loading there is a window where the agent exists but is not on a mesh.
// ResetPath and isStopped both log an error in that state.
if (agent != null && agent.isOnNavMesh)
{
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;
}
}
}