replace the NPC scaffold with data-driven prefabs, and retire the builders

NPCStandIn was written to be dropped on a sphere and carried its identity in
per-instance public fields. The cost of that showed up in the scene itself: of
the two spheres, one was the Bartender and the other was named "Bartender (1)"
in the hierarchy while being configured as the Chair — data changed, name never
was. Nothing outside the scene could see what existed, and two copies could
disagree.

Identity now lives in an InteractableDefinition asset and the scene only places
a prefab. The migration matched the old objects by yarn node rather than by
name, which is the only reason the chair ended up as the chair.

InteractableDefinition rather than the planned "character definition": it covers
a character and a talkable object equally, so Bartender.prefab sits under
Prefabs/Characters and Chair.prefab under Prefabs/Interactables.

Three components collapse into one. DialogueInteractable was in no scene at all
— a dead trigger-zone variant of the same idea. ClickInteractableBridge existed
only to adapt NPCStandIn without modifying it, a constraint that died with
NPCStandIn; DialogueInteractor implements IClickInteractable itself. The parts
worth keeping were lifted out rather than discarded: WorldSpaceLabel and
InteractionPrompt are now normal components on prefab children, so a label can
be seen and positioned in the Editor instead of existing only at runtime.
InputDeviceTracker moved into NightclubArcadia.Core and gained a namespace,
leaving no global-namespace runtime types.

Characters get the same pipeline as Skills and Candidates:
Assets/Characters/characters.json is the source of truth, CharacterSetup
regenerates the definition assets on script reload and removes orphans. Adding
an NPC is a JSON entry plus a prefab — no code, and no Unity for the data half.

Phase 5.4 deletes the one-shot builders rather than disabling them. UILayerSetup,
PlayerControlSetup, UILayerSetupAutoRun and YarnDemoSetup assumed the
single-scene layout, had already destroyed a level scene and turned Systems
binary once each, and after NPCStandIn went they no longer compiled. The prefabs
they used to generate are authored and committed; the wiring knowledge is in
those prefabs and in git history. The asset generators — Skill, Candidate,
Character — are untouched and still work.

EditMode 42/42, PlayMode 7/7, YarnCheck 9 files / 35 nodes. All three scenes
still text.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-08-25 21:53:06 +02:00
co-authored by Claude Opus 5
parent ec4619d9dc
commit 9a0618fd31
54 changed files with 2292 additions and 2195 deletions
@@ -0,0 +1,61 @@
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.InputSystem.LowLevel;
namespace NightclubArcadia.Core
{
/// <summary>
/// Which input device was most recently active, globally.
///
/// InteractionPrompt — and anything else that shows a button glyph — reads Current
/// to decide between "[F]", "(X)" and "(✕)".
///
/// Requires the Input System package, with Active Input Handling set to
/// "Input System Package (New)" or "Both".
/// </summary>
public static class InputDeviceTracker
{
public enum DeviceType { Keyboard, Xbox, PlayStation, Generic }
public static DeviceType Current { get; private set; } = DeviceType.Keyboard;
[RuntimeInitializeOnLoadMethod]
static void Init()
{
InputSystem.onEvent += OnEvent;
}
static void OnEvent(InputEventPtr eventPtr, InputDevice device)
{
// Only actual state changes — button, stick, key — not every event.
if (!eventPtr.IsA<StateEvent>() && !eventPtr.IsA<DeltaStateEvent>())
{
return;
}
switch (device)
{
case Keyboard:
case Mouse:
Current = DeviceType.Keyboard;
break;
case Gamepad pad:
var name = pad.displayName.ToLowerInvariant();
if (name.Contains("dualshock") || name.Contains("dualsense") || name.Contains("playstation"))
{
Current = DeviceType.PlayStation;
}
else if (name.Contains("xbox") || name.Contains("xinput"))
{
Current = DeviceType.Xbox;
}
else
{
Current = DeviceType.Generic;
}
break;
}
}
}
}
@@ -1,7 +1,9 @@
{
"name": "NightclubArcadia.Core",
"rootNamespace": "NightclubArcadia.Core",
"references": [],
"references": [
"Unity.InputSystem"
],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
@@ -1,93 +0,0 @@
using UnityEngine;
using UnityEngine.InputSystem;
using Yarn.Unity;
using NightclubArcadia.Core;
namespace NightclubArcadia.Dialogue
{
/// <summary>
/// Trigger-zone interactable: player walks in range, presses E, and this
/// starts the given Yarn node. The reverse of DialogueTransitionController's
/// <<enter_environment>> — this is the "step into dialogue" half of the loop.
/// </summary>
public class DialogueInteractable : MonoBehaviour
{
[SerializeField] private DialogueRunner dialogueRunner;
[SerializeField] private GameObject dialogueUI;
[SerializeField] private string yarnNodeName = "Bartender_Chat";
private bool playerInRange;
private InputAction interactAction;
void Awake()
{
// Best-effort and silent; the real lookup happens at the point of use, since
// Systems may not have loaded when this level object awakes. See Runner.
dialogueRunner = SceneServices.TryResolve(dialogueRunner);
interactAction = new InputAction("Interact");
interactAction.AddBinding("<Keyboard>/e");
interactAction.AddBinding("<Gamepad>/buttonNorth");
interactAction.performed += _ =>
{
if (playerInRange)
{
StartConversation();
}
};
}
/// <summary>
/// The dialogue runner, resolved on demand rather than in Awake, so that load
/// order between this level scene and Systems does not matter. Cached once found.
/// </summary>
DialogueRunner Runner =>
dialogueRunner != null
? dialogueRunner
: dialogueRunner = SceneServices.TryResolve<DialogueRunner>(null);
void OnEnable() => interactAction?.Enable();
void OnDisable() => interactAction?.Disable();
void OnTriggerEnter(Collider other)
{
if (other.CompareTag("Player"))
{
playerInRange = true;
}
}
void OnTriggerExit(Collider other)
{
if (other.CompareTag("Player"))
{
playerInRange = false;
}
}
async void StartConversation()
{
var runner = Runner;
if (runner == null)
{
Debug.LogError(
$"[DialogueInteractable] {name}: no DialogueRunner in any loaded scene. " +
"It lives in Systems.unity — open Bootstrap, or Tools → Nightclub Arcadia → " +
"Open Game Scenes.", this);
return;
}
// Player controls are locked by DialogueTransitionController off the
// runner's onDialogueStart event — nothing to do for that here.
if (DialogueUIVisibility.Show(dialogueUI))
{
// 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 runner.StartDialogue(yarnNodeName);
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 99afbbd28c8054d59a7a0dec94f5ec8f
@@ -11,7 +11,7 @@ namespace NightclubArcadia.Dialogue
///
/// The lock is driven by the DialogueRunner's own lifecycle events rather
/// than by each interactable, so every entry point — auto-started nodes,
/// NPCStandIn, DialogueInteractable — gets the same behaviour without
/// DialogueInteractor — gets the same behaviour without
/// having to remember to do it. Control locking is delegated to
/// <see cref="PlayerControlLock"/> so the player menu can hold a second
/// independent lock without re-enabling movement mid-conversation.
@@ -6,7 +6,7 @@ namespace NightclubArcadia.Dialogue
/// <summary>
/// Shows and hides the dialogue UI *without* deactivating its GameObject.
/// Forwards to <see cref="PanelVisibility"/> — kept as a thin wrapper so
/// existing call sites (NPCStandIn, DialogueInteractable,
/// existing call sites (DialogueInteractor,
/// DialogueTransitionController) continue to compile unchanged.
/// </summary>
public static class DialogueUIVisibility
@@ -1,29 +0,0 @@
using UnityEngine;
namespace NightclubArcadia.Interaction
{
/// <summary>
/// Adapts NPCStandIn to the click-to-interact pipeline without modifying
/// the existing proximity + keypress path.
/// </summary>
public sealed class ClickInteractableBridge : MonoBehaviour, IClickInteractable
{
[SerializeField] NPCStandIn npc;
public Transform ApproachPoint => transform;
public float ApproachRadius => npc != null ? Mathf.Max(0.15f, npc.interactionRange - 0.5f) : 0.15f;
void Awake()
{
if (npc == null)
{
npc = GetComponent<NPCStandIn>();
}
}
public void OnApproached()
{
npc?.Interact();
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 5257f07d188de44eb9a04ba4d3a65fec
@@ -0,0 +1,170 @@
using NightclubArcadia.Core;
using NightclubArcadia.Dialogue;
using NightclubArcadia.UI;
using UnityEngine;
using UnityEngine.InputSystem;
using Yarn.Unity;
namespace NightclubArcadia.Interaction
{
/// <summary>
/// Anything the player can walk up to and talk to: a character, or an object with
/// something to say.
///
/// Replaces NPCStandIn, which was written to be dropped on a sphere primitive and
/// carried its identity in per-instance public fields. Two objects could claim the
/// same character and disagree, and in practice did — the scene's second "Bartender"
/// was the chair, renamed in the data but not in the hierarchy.
///
/// Identity now comes from an InteractableDefinition asset, so a prefab is the thing
/// and the scene only places it. Also replaces DialogueInteractable, a trigger-zone
/// variant of the same idea that was in no scene at all.
/// </summary>
[RequireComponent(typeof(SphereCollider))]
public sealed class DialogueInteractor : MonoBehaviour, IClickInteractable
{
[Header("Identity")]
[SerializeField] InteractableDefinition definition;
[Header("Presentation (optional)")]
[SerializeField] WorldSpaceLabel label;
[SerializeField] InteractionPrompt prompt;
[Header("Overrides")]
[Tooltip("Leave empty to use the Canvas under the DialogueRunner.")]
[SerializeField] GameObject dialogueUI;
DialogueRunner runner;
Transform player;
InputAction interact;
bool playerInRange;
public Transform ApproachPoint => transform;
public float ApproachRadius =>
definition != null ? Mathf.Max(0.15f, definition.InteractionRange - 0.5f) : 0.15f;
/// <summary>
/// The dialogue runner, resolved on demand and cached only on success.
///
/// Never resolved once in Awake: the runner lives in the Systems scene and this
/// object lives in a level, so it can wake up before the runner exists. Caching a
/// failure there left NPCs permanently mute for the session. See SceneOrderTests.
/// </summary>
public DialogueRunner ResolvedRunner =>
runner != null ? runner : runner = SceneServices.TryResolve<DialogueRunner>(null);
Transform Player =>
player != null ? player : player = SceneServices.Player;
void Awake()
{
if (definition == null)
{
Debug.LogError($"[Interactor] {name} has no InteractableDefinition; it will do nothing.", this);
enabled = false;
return;
}
GetComponent<SphereCollider>().isTrigger = false;
if (label != null)
{
label.gameObject.SetActive(definition.ShowLabel);
label.SetText(definition.DisplayName);
}
prompt?.SetShown(false);
interact = new InputAction("Interact");
interact.AddBinding("<Keyboard>/f");
interact.AddBinding("<Gamepad>/buttonSouth");
interact.performed += _ =>
{
if (playerInRange)
{
Interact();
}
};
}
void OnEnable() => interact?.Enable();
void OnDisable() => interact?.Disable();
void Update()
{
var target = Player;
if (target == null)
{
return;
}
var inRange = Vector3.Distance(transform.position, target.position) <= definition.InteractionRange;
if (inRange == playerInRange)
{
return;
}
playerInRange = inRange;
prompt?.SetShown(inRange);
}
/// <summary>Entry point for both the proximity keypress and click-to-move arrival.</summary>
public void Interact()
{
var dialogue = ResolvedRunner;
if (dialogue == null)
{
Debug.LogError(
$"[Interactor] {definition.DisplayName}: 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 (!dialogue.IsDialogueRunning)
{
StartDialogueAsync(dialogue);
}
}
public void OnApproached() => Interact();
async void StartDialogueAsync(DialogueRunner dialogue)
{
// The dialogue UI is hidden when the player leaves a conversation via
// <<enter_environment>>. Make it visible again before the first line renders.
if (DialogueUIVisibility.Show(ResolveDialogueUI(dialogue)))
{
// 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 dialogue.StartDialogue(definition.YarnNode);
}
GameObject ResolveDialogueUI(DialogueRunner dialogue)
{
if (dialogueUI != null)
{
return dialogueUI;
}
var canvas = dialogue.GetComponentInChildren<Canvas>(true);
if (canvas == null)
{
Debug.LogWarning(
$"[Interactor] No Canvas under {dialogue.name} for {definition.DisplayName}", this);
return null;
}
return canvas.gameObject;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ba3ce042c3b3457ba101f1c6643b983f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,46 @@
using UnityEngine;
namespace NightclubArcadia.Interaction
{
/// <summary>
/// What a thing in the world is called and which conversation it starts.
///
/// This replaces a set of public fields edited per scene object. The scaffold it
/// grew out of had the name, colour, yarn node, ranges and font sizes all set on
/// each sphere by hand, which meant two objects claiming to be the same character
/// could disagree, and nothing outside the scene could see what existed.
///
/// Generated from Assets/Characters/characters.json — edit the JSON, not the asset.
/// Hand edits are overwritten, the same contract as Skills and Candidates.
/// </summary>
[CreateAssetMenu(menuName = "Nightclub Arcadia/Interactable Definition", order = 30)]
public sealed class InteractableDefinition : ScriptableObject
{
[SerializeField] string id;
[SerializeField] string displayName;
[SerializeField] string yarnNode;
[SerializeField] float interactionRange = 3f;
[Tooltip("Shown as a floating label. Off for scenery that should not announce itself.")]
[SerializeField] bool showLabel = true;
public string Id => id;
public string DisplayName => displayName;
public string YarnNode => yarnNode;
public float InteractionRange => interactionRange;
public bool ShowLabel => showLabel;
#if UNITY_EDITOR
/// <summary>Editor-only, for the generator. Not part of the runtime surface.</summary>
public void EditorSet(string newId, string newDisplayName, string newYarnNode,
float newRange, bool newShowLabel)
{
id = newId;
displayName = newDisplayName;
yarnNode = newYarnNode;
interactionRange = newRange;
showLabel = newShowLabel;
}
#endif
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8f4df63859ea4e39910d900aa7a5510a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,54 @@
using NightclubArcadia.Core;
using TMPro;
using UnityEngine;
namespace NightclubArcadia.Interaction
{
/// <summary>
/// The "[F]" that appears when the player is close enough to interact, showing the
/// glyph for whichever input device was last used.
///
/// Separated from the interactor so the prompt can be positioned and styled in a
/// prefab rather than constructed in code, and so anything else that needs a
/// proximity prompt can reuse it.
/// </summary>
[RequireComponent(typeof(TextMeshPro))]
public sealed class InteractionPrompt : MonoBehaviour
{
TextMeshPro text;
bool shown;
void Awake()
{
text = GetComponent<TextMeshPro>();
SetShown(false);
}
public void SetShown(bool value)
{
shown = value;
if (text != null)
{
text.enabled = value;
}
}
void Update()
{
if (shown && text != null)
{
text.text = GlyphFor(InputDeviceTracker.Current);
}
}
static string GlyphFor(InputDeviceTracker.DeviceType type)
{
switch (type)
{
case InputDeviceTracker.DeviceType.Xbox: return "(X)";
case InputDeviceTracker.DeviceType.PlayStation: return "(✕)";
default: return "[F]";
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 10b4e7c1069944b49acca9287528f275
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,54 @@
using TMPro;
using UnityEngine;
namespace NightclubArcadia.Interaction
{
/// <summary>
/// A floating text label that turns to face the camera.
///
/// Lifted out of NPCStandIn, which created its label in Awake and billboarded it in
/// LateUpdate. That worked, but it meant the text only existed at runtime and could
/// not be seen or positioned in the Editor. This is a normal component on a normal
/// child object, so a prefab can show what it will look like.
/// </summary>
[RequireComponent(typeof(TextMeshPro))]
public sealed class WorldSpaceLabel : MonoBehaviour
{
[SerializeField] bool billboard = true;
TextMeshPro text;
Camera view;
void Awake() => text = GetComponent<TextMeshPro>();
public void SetText(string value)
{
if (text == null)
{
text = GetComponent<TextMeshPro>();
}
text.text = value;
}
void LateUpdate()
{
if (!billboard)
{
return;
}
// Camera.main is a tagged lookup; cache it, but re-acquire if the camera was
// destroyed with a scene, which happens when the Systems scene reloads.
if (view == null)
{
view = Camera.main;
if (view == null)
{
return;
}
}
transform.rotation = Quaternion.LookRotation(transform.position - view.transform.position);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5d24ca2949394cc78a1fdde5d83e08c2
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: 2216657f3a0224501bcfa09add14f689
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,48 +0,0 @@
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.InputSystem.LowLevel;
/// <summary>
/// Tracks which input device was most recently active, globally, across the whole game.
/// NPCStandIn (and any other UI that shows button prompts) reads InputDeviceTracker.Current
/// to decide whether to show "[F]", "(X)", "(✕)", etc.
/// Requires the Input System package (Window > Package Manager > Input System),
/// and Project Settings > Player > Active Input Handling set to
/// "Input System Package (New)" or "Both".
/// </summary>
public static class InputDeviceTracker
{
public enum DeviceType { Keyboard, Xbox, PlayStation, Generic }
public static DeviceType Current { get; private set; } = DeviceType.Keyboard;
[RuntimeInitializeOnLoadMethod]
static void Init()
{
InputSystem.onEvent += OnEvent;
}
static void OnEvent(InputEventPtr eventPtr, InputDevice device)
{
// Only care about actual state changes (button/stick/key presses), not noise.
if (!eventPtr.IsA<StateEvent>() && !eventPtr.IsA<DeltaStateEvent>()) return;
switch (device)
{
case Keyboard:
case Mouse:
Current = DeviceType.Keyboard;
break;
case Gamepad gp:
string n = gp.displayName.ToLowerInvariant();
if (n.Contains("dualshock") || n.Contains("dualsense") || n.Contains("playstation"))
Current = DeviceType.PlayStation;
else if (n.Contains("xbox") || n.Contains("xinput"))
Current = DeviceType.Xbox;
else
Current = DeviceType.Generic;
break;
}
}
}
@@ -1,234 +0,0 @@
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;
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 23d0ac85377c640a1900cfd13e89c389
@@ -198,10 +198,8 @@ namespace NightclubArcadia.UI
SetPrivate(transition, "dialogueUI", canvasGo);
}
foreach (var npc in FindObjectsByType<NPCStandIn>(FindObjectsInactive.Include, FindObjectsSortMode.None))
{
npc.dialogueUI = canvasGo;
}
// Interactors used to be pushed this canvas here. DialogueInteractor resolves
// it from the runner at the point of use instead, so there is nothing to push.
}
DialogueMessageRow BuildMessageRowPrefab(Transform parent)