Fixes "[SceneServices] Bartender: could not resolve dialogueRunner". Resolving a cross-scene dependency in Awake is a race by construction. The dialogue runner lives in Systems; an NPC lives in the level. Open the level on its own, or let the scenes load in the other order, and the NPC wakes with no Systems scene in sight. Worse, the failed lookup was cached into the serialized field, so it stayed broken for the rest of the session rather than recovering once Systems appeared. NPCStandIn and DialogueInteractable now resolve lazily through a property and cache only a successful result, so any load order works. Awake still does a silent best-effort pass, which keeps the common case free. SceneServices splits in two accordingly. TryResolve is silent and is what speculative callers use; Resolve logs an error and belongs only where the dependency is genuinely required. Both now include inactive objects — a system parked inactive is still the object we mean, and excluding it reported "not found" for something sitting in the hierarchy. The error text now says where the runner lives and how to open the scenes, instead of asking a question. BootstrapTests only ever covered the happy order, which is why it stayed green while this was broken. SceneOrderTests loads the level BEFORE Systems and holds that NPCs still reach the runner. It also asserts it is reproducing the race — it counts the NPCs whose Awake-time resolution failed and fails if that is zero, so the test cannot quietly start passing for the wrong reason. It currently reports 2 of 2, which is the bug this commit fixes. EditMode 42/42, PlayMode 7/7, YarnCheck 9 files / 35 nodes. Co-Authored-By: Claude Opus 5 <[email protected]>
235 lines
7.7 KiB
C#
235 lines
7.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;
|
|
|
|
// Best-effort only, and silent. The runner lives in the Systems scene, which may
|
|
// not have loaded yet when this level's objects awake — see ResolvedDialogueRunner.
|
|
dialogueRunner = SceneServices.TryResolve(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();
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// The dialogue runner, resolved on demand.
|
|
///
|
|
/// Not resolved once in Awake: the runner lives in the Systems scene, and an NPC in
|
|
/// a level scene can wake up before that scene exists — loading the level on its own,
|
|
/// or in a different order, used to log "could not resolve dialogueRunner" and then
|
|
/// stay broken for the rest of the session. Looking it up at the point of use makes
|
|
/// load order irrelevant, and the result is cached, so this costs one search.
|
|
/// </summary>
|
|
public DialogueRunner ResolvedDialogueRunner =>
|
|
dialogueRunner != null
|
|
? dialogueRunner
|
|
: dialogueRunner = SceneServices.TryResolve<DialogueRunner>(null);
|
|
|
|
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()
|
|
{
|
|
var runner = ResolvedDialogueRunner;
|
|
if (runner == null)
|
|
{
|
|
Debug.LogError(
|
|
$"[NPCStandIn] {npcName}: no DialogueRunner in any loaded scene. " +
|
|
"It lives in Systems.unity — open Bootstrap, or Tools → Nightclub Arcadia → " +
|
|
"Open Game Scenes.", this);
|
|
return;
|
|
}
|
|
|
|
if (UIStateController.Instance != null && UIStateController.Instance.IsMenuOpen)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!runner.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 ResolvedDialogueRunner.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 runner = ResolvedDialogueRunner;
|
|
if (runner == null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var canvas = runner.GetComponentInChildren<Canvas>(true);
|
|
if (canvas == null)
|
|
{
|
|
Debug.LogWarning($"[NPCStandIn] No Canvas found under {runner.name} for {npcName}");
|
|
return null;
|
|
}
|
|
|
|
dialogueUI = canvas.gameObject;
|
|
return dialogueUI;
|
|
}
|
|
}
|