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]>
211 lines
6.7 KiB
C#
211 lines
6.7 KiB
C#
using UnityEngine;
|
|
using UnityEngine.InputSystem;
|
|
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:
|
|
/// - Sets sphere color per-instance
|
|
/// - Spawns a floating TextMeshPro name label that billboards to face the camera
|
|
/// - Shows a proximity interaction prompt ("[F]", "(X)", "(✕)") that swaps based on
|
|
/// the last input device used, via InputDeviceTracker
|
|
/// - Press the interact button while in range to start a Yarn Spinner dialogue node
|
|
/// Requires: TextMeshPro Essentials imported, Input System package installed,
|
|
/// InputDeviceTracker.cs in the project, and a "Player" tag on your player object
|
|
/// (or assign the Player field manually in the Inspector).
|
|
/// </summary>
|
|
[RequireComponent(typeof(SphereCollider))]
|
|
public class NPCStandIn : MonoBehaviour
|
|
{
|
|
[Header("Identity")]
|
|
public string npcName = "Unnamed NPC";
|
|
public Color npcColor = Color.white;
|
|
|
|
[Header("Dialogue")]
|
|
public DialogueRunner dialogueRunner;
|
|
public string yarnStartNode = "Start";
|
|
|
|
[Tooltip("Optional. Left empty, the Canvas under the DialogueRunner is used.")]
|
|
public GameObject dialogueUI;
|
|
|
|
[Header("Label")]
|
|
public float labelHeight = 1.2f;
|
|
public float labelFontSize = 4f;
|
|
public Color labelColor = Color.white;
|
|
|
|
[Header("Interaction Prompt")]
|
|
public Transform player; // auto-found by "Player" tag if left empty
|
|
public float interactionRange = 3f;
|
|
public float promptHeight = 0.7f;
|
|
public float promptFontSize = 3f;
|
|
|
|
private TextMeshPro labelText;
|
|
private TextMeshPro promptText;
|
|
private Renderer rend;
|
|
private Camera mainCam;
|
|
private InputAction interactAction;
|
|
private bool playerInRange;
|
|
|
|
void Awake()
|
|
{
|
|
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)
|
|
{
|
|
GameObject found = GameObject.FindGameObjectWithTag("Player");
|
|
if (found != null) player = found.transform;
|
|
}
|
|
|
|
CreateLabel();
|
|
CreatePrompt();
|
|
|
|
interactAction = new InputAction("Interact");
|
|
interactAction.AddBinding("<Keyboard>/f");
|
|
interactAction.AddBinding("<Gamepad>/buttonSouth");
|
|
interactAction.performed += ctx =>
|
|
{
|
|
if (playerInRange) Interact();
|
|
};
|
|
}
|
|
|
|
void OnEnable() => interactAction?.Enable();
|
|
void OnDisable() => interactAction?.Disable();
|
|
|
|
void CreateLabel()
|
|
{
|
|
GameObject labelObj = new GameObject($"{npcName}_Label");
|
|
labelObj.transform.SetParent(transform);
|
|
labelObj.transform.localPosition = Vector3.up * labelHeight;
|
|
|
|
labelText = labelObj.AddComponent<TextMeshPro>();
|
|
labelText.text = npcName;
|
|
labelText.fontSize = labelFontSize;
|
|
labelText.color = labelColor;
|
|
labelText.alignment = TextAlignmentOptions.Center;
|
|
labelText.rectTransform.sizeDelta = new Vector2(2f, 0.5f);
|
|
}
|
|
|
|
void CreatePrompt()
|
|
{
|
|
GameObject promptObj = new GameObject($"{npcName}_Prompt");
|
|
promptObj.transform.SetParent(transform);
|
|
promptObj.transform.localPosition = Vector3.up * promptHeight;
|
|
|
|
promptText = promptObj.AddComponent<TextMeshPro>();
|
|
promptText.fontSize = promptFontSize;
|
|
promptText.color = Color.yellow;
|
|
promptText.alignment = TextAlignmentOptions.Center;
|
|
promptText.rectTransform.sizeDelta = new Vector2(1.5f, 0.4f);
|
|
promptObj.SetActive(false); // hidden until player is in range
|
|
}
|
|
|
|
void Update()
|
|
{
|
|
if (player == null) return;
|
|
|
|
float dist = Vector3.Distance(transform.position, player.position);
|
|
bool inRangeNow = dist <= interactionRange;
|
|
|
|
if (inRangeNow != playerInRange)
|
|
{
|
|
playerInRange = inRangeNow;
|
|
promptText.gameObject.SetActive(playerInRange);
|
|
}
|
|
|
|
if (playerInRange)
|
|
{
|
|
promptText.text = GlyphFor(InputDeviceTracker.Current);
|
|
}
|
|
}
|
|
|
|
void LateUpdate()
|
|
{
|
|
if (mainCam == null) return;
|
|
|
|
if (labelText != null)
|
|
labelText.transform.rotation = Quaternion.LookRotation(
|
|
labelText.transform.position - mainCam.transform.position);
|
|
|
|
if (promptText != null)
|
|
promptText.transform.rotation = Quaternion.LookRotation(
|
|
promptText.transform.position - mainCam.transform.position);
|
|
}
|
|
|
|
static string GlyphFor(InputDeviceTracker.DeviceType type)
|
|
{
|
|
switch (type)
|
|
{
|
|
case InputDeviceTracker.DeviceType.Xbox: return "(X)";
|
|
case InputDeviceTracker.DeviceType.PlayStation: return "(\u2715)"; // Cross button
|
|
default: return "[F]";
|
|
}
|
|
}
|
|
|
|
public void Interact()
|
|
{
|
|
if (dialogueRunner == null)
|
|
{
|
|
Debug.LogWarning($"[NPCStandIn] No DialogueRunner assigned on {npcName}");
|
|
return;
|
|
}
|
|
|
|
if (UIStateController.Instance != null && UIStateController.Instance.IsMenuOpen)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!dialogueRunner.IsDialogueRunning)
|
|
{
|
|
StartDialogueAsync();
|
|
}
|
|
}
|
|
|
|
async void StartDialogueAsync()
|
|
{
|
|
// The dialogue UI is hidden when the player leaves a conversation via
|
|
// <<enter_environment>>. Make it visible again before the runner
|
|
// renders its first line.
|
|
if (DialogueUIVisibility.Show(ResolveDialogueUI()))
|
|
{
|
|
// Only reached if the UI was found deactivated; give TMP a frame
|
|
// to re-cache its Canvas in OnEnable before any text is generated.
|
|
await Awaitable.NextFrameAsync();
|
|
}
|
|
|
|
await dialogueRunner.StartDialogue(yarnStartNode);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The Canvas under the DialogueRunner, unless one was assigned explicitly.
|
|
/// Resolved at call time (including inactive children) so this works
|
|
/// without any per-NPC Inspector wiring.
|
|
/// </summary>
|
|
GameObject ResolveDialogueUI()
|
|
{
|
|
if (dialogueUI != null)
|
|
{
|
|
return dialogueUI;
|
|
}
|
|
|
|
var canvas = dialogueRunner.GetComponentInChildren<Canvas>(true);
|
|
if (canvas == null)
|
|
{
|
|
Debug.LogWarning($"[NPCStandIn] No Canvas found under {dialogueRunner.name} for {npcName}");
|
|
return null;
|
|
}
|
|
|
|
dialogueUI = canvas.gameObject;
|
|
return dialogueUI;
|
|
}
|
|
}
|