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,172 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!29 &1
OcclusionCullingSettings:
m_ObjectHideFlags: 0
serializedVersion: 2
m_OcclusionBakeSettings:
smallestOccluder: 5
smallestHole: 0.25
backfaceThreshold: 100
m_SceneGUID: 00000000000000000000000000000000
m_OcclusionCullingData: {fileID: 0}
--- !u!104 &2
RenderSettings:
m_ObjectHideFlags: 0
serializedVersion: 10
m_Fog: 0
m_FogColor: {r: 0.5, g: 0.5, b: 0.5, a: 1}
m_FogMode: 3
m_FogDensity: 0.01
m_LinearFogStart: 0
m_LinearFogEnd: 300
m_AmbientSkyColor: {r: 0.212, g: 0.227, b: 0.259, a: 1}
m_AmbientEquatorColor: {r: 0.114, g: 0.125, b: 0.133, a: 1}
m_AmbientGroundColor: {r: 0.047, g: 0.043, b: 0.035, a: 1}
m_AmbientIntensity: 1
m_AmbientMode: 0
m_SubtractiveShadowColor: {r: 0.42, g: 0.478, b: 0.627, a: 1}
m_SkyboxMaterial: {fileID: 10304, guid: 0000000000000000f000000000000000, type: 0}
m_HaloStrength: 0.5
m_FlareStrength: 1
m_FlareFadeSpeed: 3
m_HaloTexture: {fileID: 0}
m_SpotCookie: {fileID: 10001, guid: 0000000000000000e000000000000000, type: 0}
m_DefaultReflectionMode: 0
m_DefaultReflectionResolution: 128
m_ReflectionBounces: 1
m_ReflectionIntensity: 1
m_CustomReflection: {fileID: 0}
m_Sun: {fileID: 0}
m_UseRadianceAmbientProbe: 0
--- !u!157 &3
LightmapSettings:
m_ObjectHideFlags: 0
serializedVersion: 13
m_BakeOnSceneLoad: 0
m_GISettings:
serializedVersion: 2
m_BounceScale: 1
m_IndirectOutputScale: 1
m_AlbedoBoost: 1
m_EnvironmentLightingMode: 0
m_EnableBakedLightmaps: 1
m_EnableRealtimeLightmaps: 0
m_LightmapEditorSettings:
serializedVersion: 12
m_Resolution: 2
m_BakeResolution: 40
m_AtlasSize: 1024
m_AO: 0
m_AOMaxDistance: 1
m_CompAOExponent: 1
m_CompAOExponentDirect: 0
m_ExtractAmbientOcclusion: 0
m_Padding: 2
m_LightmapParameters: {fileID: 0}
m_LightmapsBakeMode: 1
m_TextureCompression: 1
m_ReflectionCompression: 2
m_MixedBakeMode: 2
m_BakeBackend: 2
m_PVRSampling: 1
m_PVRDirectSampleCount: 32
m_PVRSampleCount: 512
m_PVRBounces: 2
m_PVREnvironmentSampleCount: 256
m_PVREnvironmentReferencePointCount: 2048
m_PVRFilteringMode: 1
m_PVRDenoiserTypeDirect: 1
m_PVRDenoiserTypeIndirect: 1
m_PVRDenoiserTypeAO: 1
m_PVRFilterTypeDirect: 0
m_PVRFilterTypeIndirect: 0
m_PVRFilterTypeAO: 0
m_PVREnvironmentMIS: 1
m_PVRCulling: 1
m_PVRFilteringGaussRadiusDirect: 1
m_PVRFilteringGaussRadiusIndirect: 1
m_PVRFilteringGaussRadiusAO: 1
m_PVRFilteringAtrousPositionSigmaDirect: 0.5
m_PVRFilteringAtrousPositionSigmaIndirect: 2
m_PVRFilteringAtrousPositionSigmaAO: 1
m_ExportTrainingData: 0
m_TrainingDataDestination: TrainingData
m_LightProbeSampleCountMultiplier: 4
m_LightingDataAsset: {fileID: 20201, guid: 0000000000000000f000000000000000, type: 0}
m_LightingSettings: {fileID: 0}
--- !u!196 &4
NavMeshSettings:
serializedVersion: 2
m_ObjectHideFlags: 0
m_BuildSettings:
serializedVersion: 3
agentTypeID: 0
agentRadius: 0.5
agentHeight: 2
agentSlope: 45
agentClimb: 0.4
ledgeDropHeight: 0
maxJumpAcrossDistance: 0
minRegionArea: 2
manualCellSize: 0
cellSize: 0.16666667
manualTileSize: 0
tileSize: 256
buildHeightMesh: 0
maxJobWorkers: 0
preserveTilesOutsideBounds: 0
debug:
m_Flags: 0
m_NavMeshData: {fileID: 0}
--- !u!1 &1325409732
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1325409734}
- component: {fileID: 1325409733}
m_Layer: 0
m_Name: Bootstrap
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!114 &1325409733
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1325409732}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 612d8b8b7afa48d68f187e4d51bae0cd, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::NightclubArcadia.Core.GameBootstrap
systemsScene: Systems
startLevel: SC101_ConferenceHall
--- !u!4 &1325409734
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1325409732}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1660057539 &9223372036854775807
SceneRoots:
m_ObjectHideFlags: 0
m_Roots:
- {fileID: 1325409734}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 940e7dc5e84654c9fb808956223fc11e
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: c1fc9f8ca900943ce9f61d41d56bc763
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -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;
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 8ce9362d0ce746e3a38ed268d81bd866
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,121 @@
using System.Collections;
using System.Linq;
using System.Reflection;
using NUnit.Framework;
using Unity.Cinemachine;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.TestTools;
using Yarn.Unity;
namespace NightclubArcadia.PlayMode.Tests
{
/// <summary>
/// Proves the scene split actually comes up.
///
/// Splitting Systems out of the level nulled seven serialized references that
/// Unity cannot carry across a scene boundary — the two NPCs' dialogue runner, UI
/// and player fields, and the reveal camera's tracking target. Each is supposed to
/// be resolved at runtime instead. An EditMode test cannot see any of that, because
/// none of it has run. This can.
///
/// If this suite fails, the game does not start. Treat it that way.
///
/// Two checks reach their types by name through reflection rather than referencing
/// them. NPCStandIn and NightclubArcadia.Cinematics live in Assembly-CSharp, and an
/// assembly-definition test assembly cannot reference the predefined assemblies —
/// only the other way round. Giving the runtime code its own asmdefs
/// (docs/restructure-plan.md Phase 4.1) removes the need for this; until then the
/// reflection is the accommodation, and it is confined to these two tests.
/// </summary>
public class BootstrapTests
{
const string Bootstrap = "Bootstrap";
const string Systems = "Systems";
const string Level = "SC101_ConferenceHall";
[UnitySetUp]
public IEnumerator LoadFromBootstrap()
{
yield return SceneManager.LoadSceneAsync(Bootstrap, LoadSceneMode.Single);
// GameBootstrap loads the other two additively over a few frames.
var deadline = Time.realtimeSinceStartup + 20f;
while (Time.realtimeSinceStartup < deadline)
{
if (SceneManager.GetSceneByName(Systems).isLoaded &&
SceneManager.GetSceneByName(Level).isLoaded)
{
break;
}
yield return null;
}
// let Awake/Start run on everything that just loaded
for (var i = 0; i < 5; i++)
{
yield return null;
}
}
[Test]
public void Bootstrap_LoadsSystemsAndLevel()
{
Assert.IsTrue(SceneManager.GetSceneByName(Systems).isLoaded, "Systems scene did not load");
Assert.IsTrue(SceneManager.GetSceneByName(Level).isLoaded, "Level scene did not load");
Assert.AreEqual(Level, SceneManager.GetActiveScene().name,
"The level should be the active scene, so new objects and lighting land there");
}
[Test]
public void ThePlayerExists_AndIsTagged()
{
var player = GameObject.FindGameObjectWithTag("Player");
Assert.IsNotNull(player, "No object tagged Player — the reveal camera and the NPCs both need it");
}
[Test]
public void TheDialogueRunnerExists()
{
Assert.IsNotNull(Object.FindFirstObjectByType<DialogueRunner>(),
"No DialogueRunner in any loaded scene");
}
[Test]
public void EveryNpc_ResolvedItsDialogueRunner()
{
var npcs = Object.FindObjectsByType<MonoBehaviour>(FindObjectsInactive.Include, FindObjectsSortMode.None)
.Where(m => m != null && m.GetType().Name == "NPCStandIn")
.ToArray();
Assert.IsNotEmpty(npcs, "Expected NPCs in the level scene");
foreach (var npc in npcs)
{
var field = npc.GetType().GetField("dialogueRunner",
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
Assert.IsNotNull(field, "NPCStandIn no longer has a dialogueRunner field");
Assert.IsNotNull(field.GetValue(npc) as Object,
$"'{npc.name}' did not resolve a DialogueRunner. It lives in the Systems scene, " +
"so the serialized reference is gone and SceneServices.Resolve has to find it.");
}
}
[Test]
public void RevealCameras_BoundToThePlayer()
{
var bound = Object.FindObjectsByType<CinemachineCamera>(FindObjectsInactive.Include, FindObjectsSortMode.None)
.Where(cam => cam.GetComponents<MonoBehaviour>()
.Any(m => m != null && m.GetType().Name == "CinemachineFollowsPlayer"))
.ToArray();
Assert.IsNotEmpty(bound, "Expected at least one runtime-bound camera");
foreach (var cam in bound)
{
Assert.IsNotNull(cam.Follow,
$"'{cam.name}' never bound its Follow target to the player");
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 49c9ee5b850e40d3aad7b3c8ea2e85c7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,24 @@
{
"name": "NightclubArcadia.PlayMode.Tests",
"rootNamespace": "NightclubArcadia.PlayMode.Tests",
"references": [
"NightclubArcadia.Skills",
"UnityEngine.TestRunner",
"UnityEditor.TestRunner",
"Unity.Cinemachine",
"YarnSpinner.Unity"
],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": true,
"precompiledReferences": [
"nunit.framework.dll"
],
"autoReferenced": false,
"defineConstraints": [
"UNITY_INCLUDE_TESTS"
],
"versionDefines": [],
"noEngineReferences": false
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 9fa1e1d710f14f8abd1cc3f1ebd92bd3
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant: