using UnityEngine; using UnityEngine.InputSystem; using TMPro; using Yarn.Unity; using NightclubArcadia.Dialogue; using NightclubArcadia.UI; using NightclubArcadia.Core; /// /// 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). /// [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(); 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("/f"); interactAction.AddBinding("/buttonSouth"); interactAction.performed += ctx => { if (playerInRange) Interact(); }; } /// /// 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. /// public DialogueRunner ResolvedDialogueRunner => dialogueRunner != null ? dialogueRunner : dialogueRunner = SceneServices.TryResolve(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(); 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(); 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 // <>. 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); } /// /// 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. /// GameObject ResolveDialogueUI() { if (dialogueUI != null) { return dialogueUI; } var runner = ResolvedDialogueRunner; if (runner == null) { return null; } var canvas = runner.GetComponentInChildren(true); if (canvas == null) { Debug.LogWarning($"[NPCStandIn] No Canvas found under {runner.name} for {npcName}"); return null; } dialogueUI = canvas.gameObject; return dialogueUI; } }