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]>
251 lines
7.1 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|