add UI layer: dialogue stream, HUD, player menu, and skills panel

Runtime UI under Scripts/UI: a UIStateController owning menu/dialogue state, a
PlayerControlLock so opening a panel suppresses world input, panel visibility
helpers, and the menu input binding.

Views: the dialogue stream presenter and its options presenter and message row,
the portrait HUD, and the player menu with character, journal, and tab-bar
views. Skills gains SkillsPanelView and SkillRowView, which is why the Skills
asmdef now references UnityEngine.UI.

Prefabs/UI holds the authored canvases (dialogue stream, HUD, player menu) and
their rows. UILayerBootstrap remains a hand-added runtime fallback for scenes
where those prefabs are not instantiated; it deliberately does not auto-spawn.

Editor/UILayerSetup.cs is the one-shot menu that builds and wires those prefabs
into a scene, with UILayerSetupAutoRun reminding after a script reload.

Art/UI/Placeholder carries the placeholder portrait the HUD falls back to.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-08-25 19:03:42 +02:00
co-authored by Claude Opus 5
parent 932cd0582f
commit cba87dcb31
57 changed files with 7707 additions and 1 deletions
@@ -0,0 +1,82 @@
using NightclubArcadia.Dialogue;
using TMPro;
using UnityEngine;
using Yarn.Unity;
namespace NightclubArcadia.UI.Menu
{
/// <summary>
/// Character tab stub: shows player name / alias from Yarn variable storage
/// and an empty inventory placeholder.
/// </summary>
public sealed class CharacterPanelView : MonoBehaviour
{
[SerializeField] TMP_Text nameLabel;
[SerializeField] TMP_Text aliasLabel;
[SerializeField] TMP_Text inventoryPlaceholder;
[SerializeField] DialogueRunner dialogueRunner;
// No OnEnable refresh: panels are toggled via CanvasGroup, so OnEnable
// fires exactly once during scene load — before the DialogueRunner has
// initialised its variables. MenuTabBar calls Refresh on tab show,
// which is both correctly timed and repeatable.
public void Refresh()
{
if (dialogueRunner == null)
{
dialogueRunner = FindFirstObjectByType<DialogueRunner>();
}
var storage = dialogueRunner != null ? dialogueRunner.VariableStorage : null;
if (nameLabel != null)
{
nameLabel.text = ReadString(storage, "$player_name_full", "Unknown");
}
if (aliasLabel != null)
{
// $player_alias is a *smart* variable
// <<declare $player_alias = alias_name($player_name) as string>>
// and Yarn's Unity runtime never assigns
// VariableStorageBehaviour.SmartVariableEvaluator, so reading it
// through TryGetValue always throws InvalidOperationException.
// Derive it from $player_name with the same generator the Yarn
// function wraps — deterministic, so the result is identical.
var realName = ReadString(storage, "$player_name", string.Empty);
aliasLabel.text = string.IsNullOrEmpty(realName)
? "—"
: AliasNameGenerator.AliasName(realName);
}
if (inventoryPlaceholder != null)
{
inventoryPlaceholder.text = "Inventory — empty";
}
}
static string ReadString(VariableStorageBehaviour storage, string key, string fallback)
{
if (storage == null)
{
return fallback;
}
try
{
if (storage.TryGetValue<string>(key, out var value) && !string.IsNullOrEmpty(value))
{
return value;
}
}
catch (System.InvalidOperationException)
{
// Smart variables, and variables read before the runner has a
// compiled Program, throw rather than returning false. A HUD
// panel showing a fallback string is the right failure mode.
}
return fallback;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ef17c93c7dec4af2ae7ce565c8984de3
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,21 @@
using TMPro;
using UnityEngine;
namespace NightclubArcadia.UI.Menu
{
/// <summary>
/// Journal tab stub — no journal data model exists yet.
/// </summary>
public sealed class JournalPanelView : MonoBehaviour
{
[SerializeField] TMP_Text placeholderLabel;
void OnEnable()
{
if (placeholderLabel != null)
{
placeholderLabel.text = "Nothing recorded yet.";
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 74efa3bf7a354892a401418d8f56fb2b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,93 @@
using System;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.EventSystems;
using UnityEngine.UI;
namespace NightclubArcadia.UI.Menu
{
/// <summary>
/// Selects among menu tabs. Panels are toggled via CanvasGroup (never
/// SetActive) so TMP's cached Canvas stays valid. Each tab can fire an
/// Inspector-wired UnityEvent on show — used by SkillsPanelView.Refresh
/// without a cross-assembly interface.
/// </summary>
public sealed class MenuTabBar : MonoBehaviour
{
[Serializable]
public struct TabEntry
{
public Button button;
public CanvasGroup panel;
public UnityEvent onShown;
}
[SerializeField] TabEntry[] tabs = Array.Empty<TabEntry>();
int currentIndex = -1;
bool bound;
void OnEnable()
{
BindButtons();
}
void BindButtons()
{
if (bound || tabs == null)
{
return;
}
for (var i = 0; i < tabs.Length; i++)
{
var index = i;
if (tabs[i].button != null)
{
tabs[i].button.onClick.RemoveAllListeners();
tabs[i].button.onClick.AddListener(() => SelectTab(index));
}
}
bound = tabs.Length > 0;
}
/// <summary>Runtime / bootstrap entry point for assigning tabs after AddComponent.</summary>
public void SetTabs(TabEntry[] entries)
{
tabs = entries ?? System.Array.Empty<TabEntry>();
bound = false;
BindButtons();
}
public void SelectTab(int index)
{
if (tabs == null || tabs.Length == 0)
{
return;
}
index = Mathf.Clamp(index, 0, tabs.Length - 1);
currentIndex = index;
for (var i = 0; i < tabs.Length; i++)
{
var active = i == index;
var panel = tabs[i].panel;
if (panel != null)
{
panel.alpha = active ? 1f : 0f;
panel.interactable = active;
panel.blocksRaycasts = active;
}
}
tabs[index].onShown?.Invoke();
if (tabs[index].button != null && EventSystem.current != null)
{
EventSystem.current.SetSelectedGameObject(tabs[index].button.gameObject);
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: db83a1a74e0e418881880ca808f68160
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,139 @@
using UnityEngine;
namespace NightclubArcadia.UI.Menu
{
/// <summary>
/// Opens and closes the player menu from Tab (PlayerMenuInput) or an
/// external Open() call (portrait click). Holds a PlayerControlLock so
/// opening the menu during dialogue does not re-enable movement when the
/// menu is closed — the dialogue holder remains in the lock set.
/// </summary>
public sealed class PlayerMenuController : MonoBehaviour
{
[SerializeField] GameObject menuRoot;
[SerializeField] PlayerMenuInput menuInput;
[SerializeField] PlayerControlLock controlLock;
[SerializeField] MenuTabBar tabBar;
bool isOpen;
void OnEnable()
{
SubscribeToMenuInput();
}
void Start()
{
// Re-run unconditionally: when this component is created by
// UILayerBootstrap, AddComponent fires OnEnable *before* menuInput
// has been assigned, so the OnEnable subscription is a no-op.
// Subscribing again here (idempotent via -= then +=) is what makes
// Tab work on both the bootstrap and the authored-prefab paths.
SubscribeToMenuInput();
if (controlLock == null)
{
controlLock = FindFirstObjectByType<PlayerControlLock>();
}
if (menuRoot != null)
{
PanelVisibility.Hide(menuRoot);
}
}
/// <summary>
/// Resolves <see cref="menuInput"/> if unassigned and (re)subscribes.
/// Safe to call repeatedly — the -= before += keeps it single-shot.
/// </summary>
void SubscribeToMenuInput()
{
if (menuInput == null)
{
menuInput = FindFirstObjectByType<PlayerMenuInput>();
}
if (menuInput != null)
{
menuInput.MenuToggleRequested -= Toggle;
menuInput.MenuToggleRequested += Toggle;
}
}
void OnDisable()
{
if (menuInput != null)
{
menuInput.MenuToggleRequested -= Toggle;
}
if (isOpen)
{
// Ensure we release the lock if destroyed while open.
CloseInternal(notifyState: false);
}
}
public void Toggle()
{
if (isOpen)
{
Close();
}
else
{
Open();
}
}
public void Open()
{
if (isOpen)
{
return;
}
isOpen = true;
if (menuRoot != null)
{
PanelVisibility.Show(menuRoot);
}
UIStateController.Instance?.SetMenuOpen(true);
controlLock?.Acquire(this);
tabBar?.SelectTab(0);
}
public void Close()
{
if (!isOpen)
{
return;
}
CloseInternal(notifyState: true);
}
void CloseInternal(bool notifyState)
{
isOpen = false;
if (menuRoot != null)
{
PanelVisibility.Hide(menuRoot);
}
if (notifyState)
{
UIStateController.Instance?.SetMenuOpen(false);
}
// Tab during dialogue works and is the whole point of the
// ref-counted lock: the dialogue holder is still in the set, so
// closing the menu leaves the player locked and the conversation
// intact.
controlLock?.Release(this);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 118cc9783dba4b3cac1646d97ebd3998
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: