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]>
This commit is contained in:
2026-08-25 20:50:25 +02:00
co-authored by Claude Opus 5
parent 1c1e50809a
commit 40b4547c71
24 changed files with 2545 additions and 1727 deletions
@@ -0,0 +1,49 @@
using System.Collections;
using Unity.Cinemachine;
using UnityEngine;
using NightclubArcadia.Core;
namespace NightclubArcadia.Cinematics
{
/// <summary>
/// Points a CinemachineCamera at the player once the player exists.
///
/// Level cameras — the reveal cameras in particular — used to hold a serialized
/// reference to the PlayerArmature. The player now lives in the Systems scene, so
/// that reference cannot be serialized any more and has to be bound at runtime.
/// </summary>
[RequireComponent(typeof(CinemachineCamera))]
public sealed class CinemachineFollowsPlayer : MonoBehaviour
{
[Tooltip("Also drive LookAt, not just Follow.")]
[SerializeField] bool alsoLookAt;
[Tooltip("Seconds to keep retrying while the Systems scene finishes loading.")]
[SerializeField] float resolveTimeout = 5f;
IEnumerator Start()
{
var cam = GetComponent<CinemachineCamera>();
var deadline = Time.unscaledTime + resolveTimeout;
while (SceneServices.Player == null && Time.unscaledTime < deadline)
{
yield return null;
}
var player = SceneServices.Player;
if (player == null)
{
Debug.LogError($"[CinemachineFollowsPlayer] {name}: no object tagged Player appeared " +
$"within {resolveTimeout}s.", this);
yield break;
}
cam.Follow = player;
if (alsoLookAt)
{
cam.LookAt = player;
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1cc7a0e83be949f5974b1f675c37c84e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 0a917e0f38654c5d9c73026e20ad3e16
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,65 @@
using System.Collections;
using UnityEngine;
using UnityEngine.SceneManagement;
namespace NightclubArcadia.Core
{
/// <summary>
/// The entry point. Loads the persistent Systems scene, then a level, additively.
///
/// Splitting the game across scenes is what lets a level be edited without touching
/// the systems wiring, and stops a corrupted level from taking the dialogue runner,
/// the UI layer and the player with it. The cost is that serialized references
/// cannot cross a scene boundary — Unity nulls those — so the handful of level
/// objects that need a system resolve it at runtime instead. See SceneServices.
/// </summary>
public sealed class GameBootstrap : MonoBehaviour
{
[Tooltip("Persistent scene holding dialogue, skills, UI, the camera rig and the player.")]
[SerializeField] string systemsScene = "Systems";
[Tooltip("Level to open on start. Leave empty to load only the systems layer.")]
[SerializeField] string startLevel = "SC101_ConferenceHall";
IEnumerator Start()
{
yield return LoadIfNeeded(systemsScene);
if (!string.IsNullOrEmpty(startLevel))
{
yield return LoadIfNeeded(startLevel);
var level = SceneManager.GetSceneByName(startLevel);
if (level.IsValid() && level.isLoaded)
{
// The active scene decides where new objects land and which
// lighting settings apply, so it must be the level, not this one.
SceneManager.SetActiveScene(level);
}
}
}
static IEnumerator LoadIfNeeded(string sceneName)
{
if (string.IsNullOrEmpty(sceneName))
{
yield break;
}
var existing = SceneManager.GetSceneByName(sceneName);
if (existing.IsValid() && existing.isLoaded)
{
yield break;
}
var op = SceneManager.LoadSceneAsync(sceneName, LoadSceneMode.Additive);
if (op == null)
{
Debug.LogError($"[GameBootstrap] '{sceneName}' is not in the build settings.");
yield break;
}
yield return op;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 612d8b8b7afa48d68f187e4d51bae0cd
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,61 @@
using UnityEngine;
namespace NightclubArcadia.Core
{
/// <summary>
/// Finds objects that live in a different loaded scene.
///
/// Unity cannot serialize a reference across a scene boundary — the Inspector
/// refuses it and any existing reference is nulled when the scenes are split. So a
/// level object that needs the dialogue runner, or the player, has to resolve it at
/// runtime. This is a lookup helper, not a service container: there is no
/// registration step and nothing to keep in sync.
///
/// It is deliberately thin. FindFirstObjectByType is not cheap, so callers cache
/// what they get in Awake rather than calling per frame, and an Inspector-assigned
/// reference always wins — this only fills in what the split left null.
/// </summary>
public static class SceneServices
{
/// <summary>Inspector value if set, otherwise the first one in any loaded scene.</summary>
public static T Resolve<T>(T assigned, Object context, string field) where T : Object
{
if (assigned != null)
{
return assigned;
}
var found = Object.FindFirstObjectByType<T>();
if (found == null)
{
Debug.LogError(
$"[SceneServices] {context?.name}: could not resolve {field} ({typeof(T).Name}). " +
"Is the Systems scene loaded?", context);
}
return found;
}
static Transform player;
/// <summary>
/// The player transform, by tag, cached. Null between scene loads if the
/// systems scene has not come up yet, so callers must handle null.
/// </summary>
public static Transform Player
{
get
{
if (player == null)
{
var go = GameObject.FindGameObjectWithTag("Player");
player = go != null ? go.transform : null;
}
return player;
}
}
/// <summary>Drop the cache. Call when unloading a scene that held the player.</summary>
public static void Forget() => player = null;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: db6a827f17574ac1b7c92185a089300a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,6 +1,7 @@
using UnityEngine;
using UnityEngine.InputSystem;
using Yarn.Unity;
using NightclubArcadia.Core;
namespace NightclubArcadia.Dialogue
{
@@ -20,6 +21,9 @@ namespace NightclubArcadia.Dialogue
void Awake()
{
// Same cross-scene resolution as NPCStandIn: the runner is in Systems.
dialogueRunner = SceneServices.Resolve(dialogueRunner, this, nameof(dialogueRunner));
interactAction = new InputAction("Interact");
interactAction.AddBinding("<Keyboard>/e");
interactAction.AddBinding("<Gamepad>/buttonNorth");
@@ -4,6 +4,7 @@ using TMPro;
using Yarn.Unity;
using NightclubArcadia.Dialogue;
using NightclubArcadia.UI;
using NightclubArcadia.Core;
/// <summary>
/// Drop this on a sphere primitive to use it as a placeholder NPC:
@@ -53,6 +54,10 @@ public class NPCStandIn : MonoBehaviour
rend = GetComponent<Renderer>();
rend.material.color = npcColor;
// The dialogue runner lives in the Systems scene, so this reference cannot be
// serialized from a level scene. An Inspector value still wins if one is set.
dialogueRunner = SceneServices.Resolve(dialogueRunner, this, nameof(dialogueRunner));
mainCam = Camera.main;
if (player == null)
@@ -111,6 +111,12 @@ namespace NightclubArcadia.Player
return false;
}
if (!agent.isOnNavMesh)
{
pendingInteractable = null;
return false;
}
agent.stoppingDistance = 0.15f;
agent.isStopped = false;
agent.SetDestination(hit.position);
@@ -136,6 +142,12 @@ namespace NightclubArcadia.Player
return false;
}
if (!agent.isOnNavMesh)
{
pendingInteractable = null;
return false;
}
agent.stoppingDistance = Mathf.Max(0.15f, interactable.ApproachRadius);
agent.isStopped = false;
agent.SetDestination(hit.position);
@@ -147,7 +159,11 @@ namespace NightclubArcadia.Player
public void Cancel()
{
if (agent != null)
// 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;