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:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user