implemented NPC system and working bartender conversation

This commit is contained in:
2026-08-15 19:02:01 +02:00
parent be63c21e15
commit f89781d78b
18 changed files with 1042 additions and 160 deletions
@@ -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