implemented NPC system and working bartender conversation
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
using Yarn.Unity;
|
||||
|
||||
namespace NightclubArcadia.Dialogue
|
||||
{
|
||||
/// <summary>
|
||||
/// Deterministically turns the player's real name into an unrelated club
|
||||
/// alias. Same input name always yields the same alias (hash-seeded pick
|
||||
/// from two word pools), but the alias carries no resemblance to the
|
||||
/// input — it's a stage name, not a nickname derived from it.
|
||||
///
|
||||
/// Used from Yarn as a smart variable (see $player_alias in Common.yarn)
|
||||
/// or directly: {alias_name($player_name)}
|
||||
/// </summary>
|
||||
public static class AliasNameGenerator
|
||||
{
|
||||
static readonly string[] FirstWords =
|
||||
{
|
||||
"Midnight", "Velvet", "Neon", "Crimson", "Static", "Amber",
|
||||
"Obsidian", "Electric", "Hollow", "Silver", "Violet", "Iron",
|
||||
"Phantom", "Copper", "Glass", "Ashen",
|
||||
};
|
||||
|
||||
static readonly string[] SecondWords =
|
||||
{
|
||||
"Vex", "Wilde", "Sable", "Rook", "Marlowe", "Cross",
|
||||
"Vane", "Thorn", "Rye", "Blaze", "Faye", "Quinn",
|
||||
"Storm", "Reyes", "Lark", "Doyle",
|
||||
};
|
||||
|
||||
[YarnFunction("alias_name")]
|
||||
public static string AliasName(string realName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(realName))
|
||||
{
|
||||
realName = "unknown";
|
||||
}
|
||||
|
||||
var hash = Fnv1aHash(realName);
|
||||
var first = FirstWords[hash % (uint)FirstWords.Length];
|
||||
var second = SecondWords[(hash / (uint)FirstWords.Length) % (uint)SecondWords.Length];
|
||||
return $"{first} {second}";
|
||||
}
|
||||
|
||||
// FNV-1a: simple, stable across platforms and .NET versions (unlike
|
||||
// string.GetHashCode, which is not guaranteed stable run-to-run).
|
||||
static uint Fnv1aHash(string value)
|
||||
{
|
||||
const uint offsetBasis = 2166136261;
|
||||
const uint prime = 16777619;
|
||||
|
||||
var hash = offsetBasis;
|
||||
foreach (var c in value)
|
||||
{
|
||||
hash ^= c;
|
||||
hash *= prime;
|
||||
}
|
||||
|
||||
return hash;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ee985664120c84b41921806fadeccb34
|
||||
@@ -1,4 +1,3 @@
|
||||
using StarterAssets;
|
||||
using UnityEngine;
|
||||
using Yarn.Unity;
|
||||
|
||||
@@ -13,7 +12,6 @@ namespace NightclubArcadia.Dialogue
|
||||
{
|
||||
[SerializeField] private DialogueRunner dialogueRunner;
|
||||
[SerializeField] private GameObject dialogueUI;
|
||||
[SerializeField] private GameObject playerArmature;
|
||||
[SerializeField] private string yarnNodeName = "Bartender_Chat";
|
||||
|
||||
private bool playerInRange = false;
|
||||
@@ -38,14 +36,19 @@ namespace NightclubArcadia.Dialogue
|
||||
}
|
||||
}
|
||||
|
||||
void StartConversation()
|
||||
async void StartConversation()
|
||||
{
|
||||
var playerController = playerArmature.GetComponent<ThirdPersonController>();
|
||||
if (playerController != null)
|
||||
playerController.enabled = false;
|
||||
// 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();
|
||||
}
|
||||
|
||||
dialogueUI.SetActive(true);
|
||||
dialogueRunner.StartDialogue(yarnNodeName);
|
||||
await dialogueRunner.StartDialogue(yarnNodeName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,14 @@ using Yarn.Unity;
|
||||
namespace NightclubArcadia.Dialogue
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers the <<enter_environment>> Yarn command, which steps the player
|
||||
/// out of dialogue and hands control to the 3D environment.
|
||||
/// Owns the hand-off between dialogue and the 3D environment: locks the
|
||||
/// player's controls for as long as a conversation is running, and registers
|
||||
/// the <<enter_environment>> Yarn command that hands control back.
|
||||
///
|
||||
/// 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
|
||||
/// having to remember to do it.
|
||||
/// </summary>
|
||||
public class DialogueTransitionController : MonoBehaviour
|
||||
{
|
||||
@@ -15,25 +21,93 @@ namespace NightclubArcadia.Dialogue
|
||||
[SerializeField] private GameObject playerArmature;
|
||||
|
||||
private ThirdPersonController playerController;
|
||||
private StarterAssetsInputs playerInputs;
|
||||
private Animator playerAnimator;
|
||||
|
||||
static readonly int AnimIDSpeed = Animator.StringToHash("Speed");
|
||||
static readonly int AnimIDMotionSpeed = Animator.StringToHash("MotionSpeed");
|
||||
|
||||
void Awake()
|
||||
{
|
||||
if (playerArmature != null)
|
||||
{
|
||||
playerController = playerArmature.GetComponent<ThirdPersonController>();
|
||||
playerInputs = playerArmature.GetComponent<StarterAssetsInputs>();
|
||||
playerAnimator = playerArmature.GetComponent<Animator>();
|
||||
}
|
||||
|
||||
// Subscribed in Awake rather than Start: the runner auto-starts its
|
||||
// first node from its own Start(), and script execution order
|
||||
// between the two is not guaranteed.
|
||||
dialogueRunner.onDialogueStart?.AddListener(OnDialogueStart);
|
||||
dialogueRunner.onDialogueComplete?.AddListener(OnDialogueComplete);
|
||||
}
|
||||
|
||||
void Start()
|
||||
{
|
||||
playerController = playerArmature.GetComponent<ThirdPersonController>();
|
||||
|
||||
// Dialogue is in progress as soon as this controller is active (including
|
||||
// auto-started nodes), so the player stays locked out until <<enter_environment>>.
|
||||
// The armature itself stays active/visible — only its controls are disabled.
|
||||
if (playerController != null)
|
||||
playerController.enabled = false;
|
||||
// Dialogue is in progress as soon as this controller is active
|
||||
// (including auto-started nodes), so the player stays locked out
|
||||
// until <<enter_environment>>. The armature itself stays
|
||||
// active/visible — only its controls are disabled.
|
||||
SetPlayerControlEnabled(false);
|
||||
|
||||
dialogueRunner.AddCommandHandler("enter_environment", EnterEnvironment);
|
||||
}
|
||||
|
||||
void OnDestroy()
|
||||
{
|
||||
dialogueRunner.onDialogueStart?.RemoveListener(OnDialogueStart);
|
||||
dialogueRunner.onDialogueComplete?.RemoveListener(OnDialogueComplete);
|
||||
}
|
||||
|
||||
void OnDialogueStart()
|
||||
{
|
||||
DialogueUIVisibility.Show(dialogueUI);
|
||||
SetPlayerControlEnabled(false);
|
||||
}
|
||||
|
||||
// Safety net: a node that ends without <<enter_environment>> must still
|
||||
// give the player their controls back.
|
||||
void OnDialogueComplete() => EnterEnvironment();
|
||||
|
||||
void EnterEnvironment()
|
||||
{
|
||||
dialogueUI.SetActive(false);
|
||||
if (playerController != null)
|
||||
playerController.enabled = true;
|
||||
// Hide, don't deactivate: deactivating the Canvas breaks TMP's
|
||||
// cached Canvas reference and makes the next conversation throw.
|
||||
// See DialogueUIVisibility.
|
||||
DialogueUIVisibility.Hide(dialogueUI);
|
||||
SetPlayerControlEnabled(true);
|
||||
}
|
||||
|
||||
void SetPlayerControlEnabled(bool value)
|
||||
{
|
||||
if (playerController == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!value)
|
||||
{
|
||||
// Clear pending input and the locomotion blend before freezing
|
||||
// the controller. Disabling it mid-stride otherwise leaves
|
||||
// Speed at its last value, and the character keeps running on
|
||||
// the spot for the whole conversation.
|
||||
if (playerInputs != null)
|
||||
{
|
||||
playerInputs.MoveInput(Vector2.zero);
|
||||
playerInputs.LookInput(Vector2.zero);
|
||||
playerInputs.SprintInput(false);
|
||||
playerInputs.JumpInput(false);
|
||||
}
|
||||
|
||||
if (playerAnimator != null)
|
||||
{
|
||||
playerAnimator.SetFloat(AnimIDSpeed, 0f);
|
||||
playerAnimator.SetFloat(AnimIDMotionSpeed, 0f);
|
||||
}
|
||||
}
|
||||
|
||||
playerController.enabled = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace NightclubArcadia.Dialogue
|
||||
{
|
||||
/// <summary>
|
||||
/// Shows and hides the dialogue UI *without* deactivating its GameObject.
|
||||
///
|
||||
/// SetActive(false) is not a safe way to hide this UI. TextMeshProUGUI
|
||||
/// resolves its Canvas through Graphic.CacheCanvas, which calls
|
||||
/// GetComponentsInParent(includeInactive: false) and then requires
|
||||
/// canvas.isActiveAndEnabled. Once the Canvas GameObject has been
|
||||
/// deactivated, that lookup yields null, and TMP.GetTextInfo does:
|
||||
///
|
||||
/// if (m_canvas == null) m_canvas = this.canvas; // still null
|
||||
/// GenerateTextMesh(); // m_canvas.worldCamera -> NRE
|
||||
///
|
||||
/// GenerateTextMesh dereferences m_canvas unguarded, so the very next line
|
||||
/// Yarn tries to type throws NullReferenceException. Keeping the hierarchy
|
||||
/// active and driving a CanvasGroup instead means the cached Canvas
|
||||
/// reference stays valid across any number of enter/exit dialogue cycles.
|
||||
/// </summary>
|
||||
public static class DialogueUIVisibility
|
||||
{
|
||||
/// <summary>
|
||||
/// Makes the dialogue UI visible and interactive. Returns true if the
|
||||
/// GameObject had to be re-activated (legacy scene state), in which
|
||||
/// case the caller must wait one frame before rendering text so that
|
||||
/// TMP's OnEnable can re-cache its Canvas.
|
||||
/// </summary>
|
||||
public static bool Show(GameObject dialogueUI) => SetVisible(dialogueUI, true);
|
||||
|
||||
/// <summary>
|
||||
/// Hides the dialogue UI, leaving the hierarchy active. See class docs.
|
||||
/// </summary>
|
||||
public static void Hide(GameObject dialogueUI) => SetVisible(dialogueUI, false);
|
||||
|
||||
static bool SetVisible(GameObject dialogueUI, bool visible)
|
||||
{
|
||||
if (dialogueUI == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// The UI may still be deactivated by an older saved scene state or
|
||||
// by code that predates this helper. Recover from that, and tell
|
||||
// the caller a frame of grace is needed.
|
||||
var wasReactivated = false;
|
||||
if (!dialogueUI.activeSelf)
|
||||
{
|
||||
dialogueUI.SetActive(true);
|
||||
wasReactivated = true;
|
||||
}
|
||||
|
||||
var group = dialogueUI.GetComponent<CanvasGroup>();
|
||||
if (group == null)
|
||||
{
|
||||
group = dialogueUI.AddComponent<CanvasGroup>();
|
||||
}
|
||||
|
||||
group.alpha = visible ? 1f : 0f;
|
||||
group.interactable = visible;
|
||||
group.blocksRaycasts = visible;
|
||||
|
||||
return wasReactivated;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ad5dcf93105bd49b287dbd19bc42cef7
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2216657f3a0224501bcfa09add14f689
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,48 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 941f7ff9039984f729ceddebd57d4159
|
||||
@@ -0,0 +1,199 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
using TMPro;
|
||||
using Yarn.Unity;
|
||||
using NightclubArcadia.Dialogue;
|
||||
|
||||
/// <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;
|
||||
|
||||
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 (!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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 23d0ac85377c640a1900cfd13e89c389
|
||||
Reference in New Issue
Block a user