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,8 @@
fileFormatVersion: 2
guid: f0166936dfea40599b3d29101ae1c086
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,53 @@
using TMPro;
using UnityEngine;
namespace NightclubArcadia.UI.Dialogue
{
/// <summary>
/// One message row in the dialogue stream: optional speaker name + body text.
/// </summary>
public sealed class DialogueMessageRow : MonoBehaviour
{
[SerializeField] TMP_Text speakerText;
[SerializeField] TMP_Text bodyText;
[SerializeField] GameObject speakerLabel;
public TMP_Text BodyText => bodyText;
public void SetContent(string speaker, string body)
{
if (string.IsNullOrWhiteSpace(speaker))
{
if (speakerLabel != null)
{
speakerLabel.SetActive(false);
}
else if (speakerText != null)
{
speakerText.gameObject.SetActive(false);
}
}
else
{
if (speakerLabel != null)
{
speakerLabel.SetActive(true);
}
else if (speakerText != null)
{
speakerText.gameObject.SetActive(true);
}
if (speakerText != null)
{
speakerText.text = speaker;
}
}
if (bodyText != null)
{
bodyText.text = body ?? string.Empty;
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a7773ea9a6f44798aa98356903037d16
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,173 @@
using System.Collections.Generic;
using System.Threading;
using UnityEngine;
using Yarn.Unity;
// Annotations only (not warnings): DialoguePresenterBase declares
// RunOptionsAsync as YarnTask<DialogueOption?>, so the override needs a
// nullable annotation context to match it without CS8632.
#nullable enable annotations
namespace NightclubArcadia.UI.Dialogue
{
/// <summary>
/// Presents Yarn options as rows at the bottom of the dialogue stream,
/// reusing the stock OptionItem component from the Yarn package.
/// </summary>
public sealed class DialogueStreamOptionsPresenter : DialoguePresenterBase
{
[SerializeField] OptionItem optionViewPrefab;
[SerializeField] Transform optionsContainer;
[SerializeField] CanvasGroup canvasGroup;
[SerializeField] bool showUnavailableOptions;
readonly List<OptionItem> optionViews = new List<OptionItem>();
public override YarnTask OnDialogueStartedAsync() => YarnTask.CompletedTask;
public override YarnTask OnDialogueCompleteAsync()
{
HideAllOptions();
return YarnTask.CompletedTask;
}
public override YarnTask RunLineAsync(LocalizedLine line, LineCancellationToken token)
{
// Lines are handled by DialogueStreamPresenter.
return YarnTask.CompletedTask;
}
public override async YarnTask<DialogueOption?> RunOptionsAsync(
DialogueOption[] dialogueOptions,
LineCancellationToken cancellationToken)
{
var anyAvailable = false;
foreach (var option in dialogueOptions)
{
if (option.IsAvailable)
{
anyAvailable = true;
break;
}
}
if (!anyAvailable)
{
return null;
}
while (dialogueOptions.Length > optionViews.Count)
{
optionViews.Add(CreateNewOptionView());
}
var selectedOptionCompletionSource = new YarnTaskCompletionSource<DialogueOption?>();
var completionCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken.NextContentToken);
async YarnTask CancelSourceWhenDialogueCancelled()
{
await YarnTask.WaitUntilCanceled(completionCancellationSource.Token);
if (cancellationToken.IsNextContentRequested)
{
selectedOptionCompletionSource.TrySetResult(null);
}
}
CancelSourceWhenDialogueCancelled().Forget();
for (var i = 0; i < dialogueOptions.Length; i++)
{
var optionView = optionViews[i];
var option = dialogueOptions[i];
if (!option.IsAvailable && !showUnavailableOptions)
{
continue;
}
optionView.gameObject.SetActive(true);
optionView.Option = option;
optionView.OnOptionSelected = selectedOptionCompletionSource;
optionView.completionToken = completionCancellationSource.Token;
}
var optionIndexToSelect = -1;
for (var i = 0; i < optionViews.Count; i++)
{
var view = optionViews[i];
if (!view.isActiveAndEnabled)
{
continue;
}
if (view.IsHighlighted)
{
optionIndexToSelect = i;
break;
}
if (optionIndexToSelect == -1)
{
optionIndexToSelect = i;
}
}
if (optionIndexToSelect > -1)
{
optionViews[optionIndexToSelect].Select();
}
if (canvasGroup != null)
{
canvasGroup.alpha = 1f;
canvasGroup.interactable = true;
canvasGroup.blocksRaycasts = true;
}
var completedTask = await selectedOptionCompletionSource.Task;
completionCancellationSource.Cancel();
if (canvasGroup != null)
{
canvasGroup.interactable = false;
canvasGroup.blocksRaycasts = false;
canvasGroup.alpha = 0f;
}
HideAllOptions();
await YarnTask.Yield();
if (cancellationToken.NextContentToken.IsCancellationRequested)
{
return await DialogueRunner.NoOptionSelected;
}
return completedTask;
}
OptionItem CreateNewOptionView()
{
var parent = optionsContainer != null
? optionsContainer
: (canvasGroup != null ? canvasGroup.transform : transform);
var optionView = Instantiate(optionViewPrefab, parent, false);
optionView.transform.SetAsLastSibling();
optionView.gameObject.SetActive(false);
return optionView;
}
void HideAllOptions()
{
foreach (var optionView in optionViews)
{
if (optionView != null)
{
optionView.gameObject.SetActive(false);
}
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8d86cc99d5b545c4a3fad9e1482031a2
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,133 @@
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
using Yarn.Unity;
// Annotations only (not warnings): DialoguePresenterBase declares
// RunOptionsAsync as YarnTask<DialogueOption?>, so the override needs a
// nullable annotation context to match it without CS8632.
#nullable enable annotations
namespace NightclubArcadia.UI.Dialogue
{
/// <summary>
/// Yarn presenter that appends each line to a scrolling chat-style stream
/// rather than replacing a single text box.
/// </summary>
public sealed class DialogueStreamPresenter : DialoguePresenterBase
{
[SerializeField] DialogueMessageRow messageRowPrefab;
[SerializeField] RectTransform messageContainer;
[SerializeField] ScrollRect scrollRect;
[SerializeField] int maxRows = 100;
[SerializeField] float lettersPerSecond = 60f;
readonly List<DialogueMessageRow> rows = new List<DialogueMessageRow>();
void Awake()
{
// Must exist before LineAdvancer.Start(), which does
// presenter.Typewriter?.ActionMarkupHandlers.Add(this);
// to learn when a line has finished displaying. Leave Typewriter
// null and that is a silent no-op: LineAdvancer never receives
// OnLineDisplayComplete, its status never leaves LineBegan, and
// pressing the advance key only ever hurries up the line — the
// conversation can never move past the first message.
Typewriter = new LetterTypewriter
{
CharactersPerSecond = lettersPerSecond,
};
}
public override YarnTask OnDialogueStartedAsync()
{
ClearRows();
return YarnTask.CompletedTask;
}
public override YarnTask OnDialogueCompleteAsync()
{
// DialogueTransitionController owns showing/hiding the panel.
return YarnTask.CompletedTask;
}
public override async YarnTask RunLineAsync(LocalizedLine line, LineCancellationToken token)
{
if (messageRowPrefab == null || messageContainer == null)
{
Debug.LogError($"[{nameof(DialogueStreamPresenter)}] Missing messageRowPrefab or messageContainer.", this);
return;
}
var row = Instantiate(messageRowPrefab, messageContainer);
row.gameObject.SetActive(true);
rows.Add(row);
while (rows.Count > maxRows)
{
var oldest = rows[0];
rows.RemoveAt(0);
if (oldest != null)
{
Destroy(oldest.gameObject);
}
}
var text = line.TextWithoutCharacterName;
row.SetContent(line.CharacterName, text.Text ?? string.Empty);
// Delegate the reveal to Yarn's own typewriter rather than
// hand-rolling maxVisibleCharacters. It drives the
// IActionMarkupHandler lifecycle — including LineAdvancer's
// OnLineDisplayComplete, which is what lets the advance key move to
// the next line instead of only hurrying up the current one — and
// it honours [pause] markup for free.
//
// TextElement is re-pointed at each new row; the typewriter itself
// is reused so LineAdvancer's registration survives.
if (Typewriter != null && row.BodyText != null)
{
Typewriter.TextElement = row.BodyText;
Typewriter.PrepareForContent(text);
await Typewriter.RunTypewriter(text, token.HurryUpToken).SuppressCancellationThrow();
}
// Deliberately no ContentWillDismiss/ContentDidDismiss: rows persist
// in the stream, and ContentDidDismiss would blank the row by
// resetting maxVisibleCharacters to 0.
Canvas.ForceUpdateCanvases();
if (scrollRect != null)
{
scrollRect.verticalNormalizedPosition = 0f;
}
if (!token.IsNextContentRequested)
{
await YarnTask.WaitUntilCanceled(token.NextContentToken).SuppressCancellationThrow();
}
}
public override YarnTask<DialogueOption?> RunOptionsAsync(
DialogueOption[] dialogueOptions,
LineCancellationToken cancellationToken)
{
// Options are handled by DialogueStreamOptionsPresenter.
return DialogueRunner.NoOptionSelected;
}
void ClearRows()
{
for (var i = 0; i < rows.Count; i++)
{
if (rows[i] != null)
{
Destroy(rows[i].gameObject);
}
}
rows.Clear();
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 88cb6495795a4e13ac7fb72343c132af
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 43777cd37e914ac1872021e3de3ceaed
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,65 @@
using NightclubArcadia.UI;
using UnityEngine;
namespace NightclubArcadia.UI.HUD
{
/// <summary>
/// Bottom-right player portrait. Visible only when dialogue is inactive
/// and the player menu is closed.
/// </summary>
public sealed class PortraitHudView : MonoBehaviour
{
[SerializeField] GameObject panelRoot;
void OnEnable()
{
if (UIStateController.Instance != null)
{
UIStateController.Instance.StateChanged += OnStateChanged;
}
}
void Start()
{
// Re-subscribe in case Instance was not ready during OnEnable,
// and apply the initial visibility.
if (UIStateController.Instance != null)
{
UIStateController.Instance.StateChanged -= OnStateChanged;
UIStateController.Instance.StateChanged += OnStateChanged;
}
ApplyVisibility();
}
void OnDisable()
{
if (UIStateController.Instance != null)
{
UIStateController.Instance.StateChanged -= OnStateChanged;
}
}
void OnStateChanged() => ApplyVisibility();
void ApplyVisibility()
{
if (panelRoot == null)
{
return;
}
var state = UIStateController.Instance;
var visible = state == null || (!state.IsDialogueActive && !state.IsMenuOpen);
if (visible)
{
PanelVisibility.Show(panelRoot);
}
else
{
PanelVisibility.Hide(panelRoot);
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: fd1354ade14b4b1d80da9acac4aa9e12
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 36b77035604148a0bec372b5f51e0f5f
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -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:
@@ -0,0 +1,67 @@
using UnityEngine;
namespace NightclubArcadia.UI
{
/// <summary>
/// Shows and hides a UI panel *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 PanelVisibility
{
/// <summary>
/// Makes the panel 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 panel) => SetVisible(panel, true);
/// <summary>
/// Hides the panel, leaving the hierarchy active. See class docs.
/// </summary>
public static void Hide(GameObject panel) => SetVisible(panel, false);
static bool SetVisible(GameObject panel, bool visible)
{
if (panel == 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 (!panel.activeSelf)
{
panel.SetActive(true);
wasReactivated = true;
}
var group = panel.GetComponent<CanvasGroup>();
if (group == null)
{
group = panel.AddComponent<CanvasGroup>();
}
group.alpha = visible ? 1f : 0f;
group.interactable = visible;
group.blocksRaycasts = visible;
return wasReactivated;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 988359a7b8f64e3aa42a4527989ec6e7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,101 @@
using System.Collections.Generic;
using NightclubArcadia.Player;
using StarterAssets;
using UnityEngine;
namespace NightclubArcadia.UI
{
/// <summary>
/// Ref-counted freeze of the player's ThirdPersonController. Multiple
/// independent holders (dialogue, menu) can Acquire/Release; the controller
/// stays disabled until the last holder releases.
/// </summary>
public sealed class PlayerControlLock : MonoBehaviour
{
readonly HashSet<object> holders = new HashSet<object>();
ThirdPersonController playerController;
StarterAssetsInputs playerInputs;
Animator playerAnimator;
PlayerMotionArbiter motionArbiter;
static readonly int AnimIDSpeed = Animator.StringToHash("Speed");
static readonly int AnimIDMotionSpeed = Animator.StringToHash("MotionSpeed");
public bool IsLocked => holders.Count > 0;
void Awake()
{
playerController = GetComponent<ThirdPersonController>();
playerInputs = GetComponent<StarterAssetsInputs>();
playerAnimator = GetComponent<Animator>();
motionArbiter = GetComponent<PlayerMotionArbiter>();
}
public void Acquire(object reason)
{
if (reason == null)
{
throw new System.ArgumentNullException(nameof(reason));
}
var wasLocked = IsLocked;
holders.Add(reason);
if (!wasLocked && IsLocked)
{
ApplyLock(true);
}
}
public void Release(object reason)
{
if (reason == null)
{
return;
}
var wasLocked = IsLocked;
holders.Remove(reason);
if (wasLocked && !IsLocked)
{
ApplyLock(false);
}
}
void ApplyLock(bool locked)
{
if (playerController == null)
{
return;
}
if (locked)
{
// 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);
}
motionArbiter?.ResetState();
}
// cursor state hook — see plan §3.4
playerController.enabled = !locked;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 2323f96ac8bc4e7080b4e3dac9a23795
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,24 @@
using System;
using UnityEngine;
using UnityEngine.InputSystem;
namespace NightclubArcadia.UI
{
/// <summary>
/// Receives the Player/Menu SendMessage from PlayerInput (sibling of
/// StarterAssetsInputs on PlayerArmature) and raises
/// <see cref="MenuToggleRequested"/>.
/// </summary>
public sealed class PlayerMenuInput : MonoBehaviour
{
public event Action MenuToggleRequested;
void OnMenu(InputValue value)
{
if (value.isPressed)
{
MenuToggleRequested?.Invoke();
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d08e055ec81347c4bddb8231f5c20cd2
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,646 @@
using NightclubArcadia.Cinematics;
using NightclubArcadia.Dialogue;
using NightclubArcadia.Skills;
using NightclubArcadia.UI.Dialogue;
using NightclubArcadia.UI.HUD;
using NightclubArcadia.UI.Menu;
using StarterAssets;
using TMPro;
using Unity.Cinemachine;
using UnityEngine;
using UnityEngine.UI;
using Yarn.Unity;
namespace NightclubArcadia.UI
{
/// <summary>
/// Builds the rudimentary UI layer at runtime, for scenes where the UI
/// prefabs have not been instantiated. It is a fallback only: the authored
/// path is Tools → Nightclub Arcadia → Set Up UI Layer, which produces the
/// prefabs under Assets/Prefabs/UI and wires them into the scene.
///
/// It does NOT auto-spawn. It used to, which meant that once the scene had
/// been wired properly it would still create a second "UI System" object
/// carrying a duplicate UIStateController — logging a Debug.LogError every
/// play — and would reflection-overwrite DialogueCameraFraming's serialised
/// dialogueScreenX, making that value impossible to tune in the Inspector.
/// Add this component to a scene by hand if you want the fallback.
/// </summary>
[DefaultExecutionOrder(-100)]
public sealed class UILayerBootstrap : MonoBehaviour
{
[SerializeField] bool buildIfMissing = true;
void Awake()
{
EnsureFoundation();
if (!buildIfMissing)
{
return;
}
if (FindFirstObjectByType<DialogueStreamPresenter>() == null)
{
BuildDialogueStream();
}
if (FindFirstObjectByType<PortraitHudView>() == null)
{
BuildHud();
}
if (FindFirstObjectByType<PlayerMenuController>() == null)
{
BuildMenu();
}
EnsureCameraFraming();
WireDialogueRunner();
HidePackageDialogueCanvas();
}
void EnsureFoundation()
{
if (UIStateController.Instance == null && FindFirstObjectByType<UIStateController>() == null)
{
var go = GameObject.Find("UI System") ?? new GameObject("UI System");
go.AddComponent<UIStateController>();
}
var player = GameObject.Find("PlayerArmature");
if (player == null)
{
return;
}
if (player.GetComponent<PlayerControlLock>() == null)
{
player.AddComponent<PlayerControlLock>();
}
if (player.GetComponent<PlayerMenuInput>() == null)
{
player.AddComponent<PlayerMenuInput>();
}
var inputs = player.GetComponent<StarterAssetsInputs>();
if (inputs != null)
{
inputs.cursorLocked = false;
Cursor.lockState = CursorLockMode.None;
Cursor.visible = true;
}
var transition = FindFirstObjectByType<DialogueTransitionController>();
if (transition != null)
{
var soLock = player.GetComponent<PlayerControlLock>();
// Assign via reflection-free serialized path: public setter not available,
// so use SendMessage-style field via SerializedObject is editor-only.
// Runtime: DialogueTransitionController finds lock from playerArmature in Awake.
var field = typeof(DialogueTransitionController).GetField(
"controlLock",
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
field?.SetValue(transition, soLock);
}
}
void BuildDialogueStream()
{
var canvasGo = CreateCanvas("DialogueStreamCanvas", 5);
var canvasGroup = canvasGo.AddComponent<CanvasGroup>();
canvasGroup.alpha = 0f;
canvasGroup.interactable = false;
canvasGroup.blocksRaycasts = false;
var panel = new GameObject("Panel", typeof(RectTransform), typeof(Image));
panel.transform.SetParent(canvasGo.transform, false);
var panelRt = panel.GetComponent<RectTransform>();
panelRt.anchorMin = new Vector2(0.66f, 0f);
panelRt.anchorMax = Vector2.one;
panelRt.offsetMin = Vector2.zero;
panelRt.offsetMax = Vector2.zero;
panel.GetComponent<Image>().color = new Color(0.05f, 0.05f, 0.08f, 0.92f);
var scrollGo = new GameObject("Scroll", typeof(RectTransform), typeof(ScrollRect), typeof(Image));
scrollGo.transform.SetParent(panel.transform, false);
Stretch(scrollGo.GetComponent<RectTransform>(), 8f);
scrollGo.GetComponent<Image>().color = new Color(0f, 0f, 0f, 0.01f);
var viewport = new GameObject("Viewport", typeof(RectTransform), typeof(RectMask2D), typeof(Image));
viewport.transform.SetParent(scrollGo.transform, false);
Stretch(viewport.GetComponent<RectTransform>());
viewport.GetComponent<Image>().color = new Color(1f, 1f, 1f, 0.01f);
var content = new GameObject("Content", typeof(RectTransform), typeof(VerticalLayoutGroup), typeof(ContentSizeFitter));
content.transform.SetParent(viewport.transform, false);
var contentRt = content.GetComponent<RectTransform>();
contentRt.anchorMin = new Vector2(0f, 0f);
contentRt.anchorMax = new Vector2(1f, 0f);
contentRt.pivot = new Vector2(0.5f, 0f);
var vlg = content.GetComponent<VerticalLayoutGroup>();
vlg.childAlignment = TextAnchor.LowerCenter;
vlg.childControlHeight = true;
vlg.childControlWidth = true;
vlg.childForceExpandHeight = false;
vlg.childForceExpandWidth = true;
vlg.spacing = 6f;
content.GetComponent<ContentSizeFitter>().verticalFit = ContentSizeFitter.FitMode.PreferredSize;
var messages = new GameObject("Messages", typeof(RectTransform), typeof(VerticalLayoutGroup), typeof(ContentSizeFitter));
messages.transform.SetParent(content.transform, false);
ConfigureVertical(messages);
var options = new GameObject("Options", typeof(RectTransform), typeof(VerticalLayoutGroup), typeof(ContentSizeFitter), typeof(CanvasGroup));
options.transform.SetParent(content.transform, false);
ConfigureVertical(options);
var optionsGroup = options.GetComponent<CanvasGroup>();
optionsGroup.alpha = 0f;
optionsGroup.interactable = false;
optionsGroup.blocksRaycasts = false;
var scroll = scrollGo.GetComponent<ScrollRect>();
scroll.content = contentRt;
scroll.viewport = viewport.GetComponent<RectTransform>();
scroll.horizontal = false;
scroll.vertical = true;
var templates = new GameObject("UITemplates");
templates.transform.SetParent(transform, false);
templates.SetActive(false);
var rowPrefab = BuildMessageRowPrefab(templates.transform);
var stream = canvasGo.AddComponent<DialogueStreamPresenter>();
SetPrivate(stream, "messageRowPrefab", rowPrefab);
SetPrivate(stream, "messageContainer", messages.GetComponent<RectTransform>());
SetPrivate(stream, "scrollRect", scroll);
OptionItem yarnOption = null;
#if UNITY_EDITOR
yarnOption = UnityEditor.AssetDatabase.LoadAssetAtPath<OptionItem>(
"Packages/dev.yarnspinner.unity/Prefabs/Option Item.prefab");
#endif
if (yarnOption == null)
{
yarnOption = BuildFallbackOptionItem(templates.transform);
}
var optionsPresenter = canvasGo.AddComponent<DialogueStreamOptionsPresenter>();
SetPrivate(optionsPresenter, "optionViewPrefab", yarnOption);
SetPrivate(optionsPresenter, "optionsContainer", options.transform);
SetPrivate(optionsPresenter, "canvasGroup", optionsGroup);
var transition = FindFirstObjectByType<DialogueTransitionController>();
if (transition != null)
{
SetPrivate(transition, "dialogueUI", canvasGo);
}
foreach (var npc in FindObjectsByType<NPCStandIn>(FindObjectsInactive.Include, FindObjectsSortMode.None))
{
npc.dialogueUI = canvasGo;
}
}
DialogueMessageRow BuildMessageRowPrefab(Transform parent)
{
var root = new GameObject("DialogueMessageRow", typeof(RectTransform), typeof(DialogueMessageRow), typeof(VerticalLayoutGroup), typeof(ContentSizeFitter));
root.transform.SetParent(parent, false);
var layout = root.GetComponent<VerticalLayoutGroup>();
layout.padding = new RectOffset(12, 12, 8, 8);
layout.spacing = 4f;
layout.childControlHeight = true;
layout.childControlWidth = true;
layout.childForceExpandWidth = true;
layout.childForceExpandHeight = false;
root.GetComponent<ContentSizeFitter>().verticalFit = ContentSizeFitter.FitMode.PreferredSize;
var speaker = CreateTmp("Speaker", root.transform, 22f, FontStyles.Bold);
var body = CreateTmp("Body", root.transform, 28f, FontStyles.Normal);
var row = root.GetComponent<DialogueMessageRow>();
SetPrivate(row, "speakerText", speaker);
SetPrivate(row, "bodyText", body);
SetPrivate(row, "speakerLabel", speaker.gameObject);
return row;
}
OptionItem BuildFallbackOptionItem(Transform parent)
{
var go = new GameObject("OptionItem", typeof(RectTransform), typeof(Image), typeof(OptionItem));
go.transform.SetParent(parent, false);
go.GetComponent<Image>().color = new Color(0.15f, 0.15f, 0.2f, 1f);
var text = CreateTmp("Text", go.transform, 24f, FontStyles.Normal);
Stretch(text.rectTransform);
SetPrivate(go.GetComponent<OptionItem>(), "text", text);
return go.GetComponent<OptionItem>();
}
void BuildHud()
{
var canvasGo = CreateCanvas("HudCanvas", 10);
var panel = new GameObject("PortraitPanel", typeof(RectTransform), typeof(CanvasGroup), typeof(Image), typeof(Button));
panel.transform.SetParent(canvasGo.transform, false);
var rt = panel.GetComponent<RectTransform>();
rt.anchorMin = new Vector2(1f, 0f);
rt.anchorMax = new Vector2(1f, 0f);
rt.pivot = new Vector2(1f, 0f);
rt.sizeDelta = new Vector2(220f, 280f);
rt.anchoredPosition = new Vector2(-24f, 24f);
panel.GetComponent<Image>().color = new Color(0.15f, 0.15f, 0.18f, 0.95f);
// Lives in Assets/Art/UI/Placeholder/Resources so this resolves in
// builds too — the AssetDatabase fallback below is editor-only.
var sprite = Resources.Load<Sprite>("portrait_placeholder");
#if UNITY_EDITOR
if (sprite == null)
{
sprite = UnityEditor.AssetDatabase.LoadAssetAtPath<Sprite>(
"Assets/Art/UI/Placeholder/Resources/portrait_placeholder.png");
}
#endif
if (sprite != null)
{
panel.GetComponent<Image>().sprite = sprite;
panel.GetComponent<Image>().preserveAspect = true;
}
var hint = CreateTmp("TabHint", panel.transform, 18f, FontStyles.Normal);
hint.text = "Tab";
hint.alignment = TextAlignmentOptions.Center;
var hintRt = hint.rectTransform;
hintRt.anchorMin = new Vector2(0f, 0f);
hintRt.anchorMax = new Vector2(1f, 0f);
hintRt.pivot = new Vector2(0.5f, 0f);
hintRt.sizeDelta = new Vector2(0f, 28f);
hintRt.anchoredPosition = new Vector2(0f, 4f);
var view = canvasGo.AddComponent<PortraitHudView>();
SetPrivate(view, "panelRoot", panel);
}
void BuildMenu()
{
var canvasGo = CreateCanvas("PlayerMenuCanvas", 20);
var rootGroup = canvasGo.AddComponent<CanvasGroup>();
rootGroup.alpha = 0f;
rootGroup.interactable = false;
rootGroup.blocksRaycasts = false;
var dimmer = new GameObject("Dimmer", typeof(RectTransform), typeof(Image));
dimmer.transform.SetParent(canvasGo.transform, false);
Stretch(dimmer.GetComponent<RectTransform>());
dimmer.GetComponent<Image>().color = new Color(0f, 0f, 0f, 0.6f);
var body = new GameObject("MenuBody", typeof(RectTransform), typeof(Image));
body.transform.SetParent(canvasGo.transform, false);
var bodyRt = body.GetComponent<RectTransform>();
bodyRt.anchorMin = new Vector2(0.15f, 0.1f);
bodyRt.anchorMax = new Vector2(0.85f, 0.9f);
bodyRt.offsetMin = Vector2.zero;
bodyRt.offsetMax = Vector2.zero;
body.GetComponent<Image>().color = new Color(0.08f, 0.08f, 0.1f, 0.98f);
var tabBarGo = new GameObject("TabBar", typeof(RectTransform), typeof(HorizontalLayoutGroup), typeof(MenuTabBar));
tabBarGo.transform.SetParent(body.transform, false);
var tabBarRt = tabBarGo.GetComponent<RectTransform>();
tabBarRt.anchorMin = new Vector2(0f, 1f);
tabBarRt.anchorMax = new Vector2(1f, 1f);
tabBarRt.pivot = new Vector2(0.5f, 1f);
tabBarRt.sizeDelta = new Vector2(0f, 56f);
var tabHlg = tabBarGo.GetComponent<HorizontalLayoutGroup>();
tabHlg.spacing = 8f;
tabHlg.padding = new RectOffset(12, 12, 8, 8);
// childControl* default to false on a script-added layout group.
tabHlg.childControlWidth = true;
tabHlg.childControlHeight = true;
tabHlg.childForceExpandWidth = true;
tabHlg.childForceExpandHeight = true;
var contentArea = new GameObject("ContentArea", typeof(RectTransform));
contentArea.transform.SetParent(body.transform, false);
var contentRt = contentArea.GetComponent<RectTransform>();
contentRt.anchorMin = Vector2.zero;
contentRt.anchorMax = Vector2.one;
contentRt.offsetMin = new Vector2(16f, 16f);
contentRt.offsetMax = new Vector2(-16f, -64f);
var characterPanel = CreateTabPanel("CharacterPanel", contentArea.transform);
var skillsPanel = CreateTabPanel("SkillsPanel", contentArea.transform);
var journalPanel = CreateTabPanel("JournalPanel", contentArea.transform);
// Character
var charVlg = characterPanel.AddComponent<VerticalLayoutGroup>();
charVlg.spacing = 12f;
charVlg.padding = new RectOffset(8, 8, 8, 8);
charVlg.childControlWidth = true;
charVlg.childControlHeight = true;
charVlg.childForceExpandWidth = true;
var charView = characterPanel.AddComponent<CharacterPanelView>();
var nameLabel = CreateTmp("Name", characterPanel.transform, 36f, FontStyles.Bold);
var aliasLabel = CreateTmp("Alias", characterPanel.transform, 28f, FontStyles.Italic);
var invLabel = CreateTmp("Inventory", characterPanel.transform, 24f, FontStyles.Normal);
SetPrivate(charView, "nameLabel", nameLabel);
SetPrivate(charView, "aliasLabel", aliasLabel);
SetPrivate(charView, "inventoryPlaceholder", invLabel);
// Skills
var skillsContent = new GameObject("Content", typeof(RectTransform), typeof(VerticalLayoutGroup), typeof(ContentSizeFitter));
skillsContent.transform.SetParent(skillsPanel.transform, false);
Stretch(skillsContent.GetComponent<RectTransform>());
ConfigureVertical(skillsContent);
skillsContent.GetComponent<RectTransform>().anchorMin = new Vector2(0f, 1f);
skillsContent.GetComponent<RectTransform>().anchorMax = new Vector2(1f, 1f);
skillsContent.GetComponent<RectTransform>().pivot = new Vector2(0.5f, 1f);
var axisHeader = CreateTmp("AxisHeaderTemplate", skillsContent.transform, 20f, FontStyles.Bold);
axisHeader.gameObject.SetActive(false);
var templates = GameObject.Find("UI System")?.transform.Find("UITemplates")
?? new GameObject("UITemplates").transform;
if (templates.parent == null && transform != null)
{
templates.SetParent(transform, false);
templates.gameObject.SetActive(false);
}
var rowPrefab = BuildSkillRowPrefab(templates);
var skillsView = skillsPanel.AddComponent<SkillsPanelView>();
SetPrivate(skillsView, "contentRoot", skillsContent.GetComponent<RectTransform>());
SetPrivate(skillsView, "rowPrefab", rowPrefab);
SetPrivate(skillsView, "axisHeaderPrefab", axisHeader);
SetPrivate(skillsView, "displayMaxRank", 6);
// Journal
var journalView = journalPanel.AddComponent<JournalPanelView>();
var journalLabel = CreateTmp("Placeholder", journalPanel.transform, 28f, FontStyles.Italic);
journalLabel.text = "Nothing recorded yet.";
journalLabel.alignment = TextAlignmentOptions.Center;
Stretch(journalLabel.rectTransform);
SetPrivate(journalView, "placeholderLabel", journalLabel);
var charBtn = CreateTabButton("Character", tabBarGo.transform);
var skillsBtn = CreateTabButton("Skills", tabBarGo.transform);
var journalBtn = CreateTabButton("Journal", tabBarGo.transform);
var tabBar = tabBarGo.GetComponent<MenuTabBar>();
var tabs = new MenuTabBar.TabEntry[3];
tabs[0] = new MenuTabBar.TabEntry
{
button = charBtn,
panel = characterPanel.GetComponent<CanvasGroup>(),
onShown = new UnityEngine.Events.UnityEvent(),
};
// Panels toggle via CanvasGroup, never SetActive, so OnEnable fires
// once at construction and never again — before Yarn has set
// $player_name_full. Refresh on show or the tab stays stale forever.
tabs[0].onShown.AddListener(charView.Refresh);
tabs[1] = new MenuTabBar.TabEntry
{
button = skillsBtn,
panel = skillsPanel.GetComponent<CanvasGroup>(),
onShown = new UnityEngine.Events.UnityEvent(),
};
tabs[1].onShown.AddListener(skillsView.Refresh);
tabs[2] = new MenuTabBar.TabEntry
{
button = journalBtn,
panel = journalPanel.GetComponent<CanvasGroup>(),
onShown = new UnityEngine.Events.UnityEvent(),
};
tabBar.SetTabs(tabs);
var player = GameObject.Find("PlayerArmature");
var menuController = canvasGo.AddComponent<PlayerMenuController>();
SetPrivate(menuController, "menuRoot", canvasGo);
SetPrivate(menuController, "menuInput", player != null ? player.GetComponent<PlayerMenuInput>() : null);
SetPrivate(menuController, "controlLock", player != null ? player.GetComponent<PlayerControlLock>() : null);
SetPrivate(menuController, "tabBar", tabBar);
// Portrait click
var portraitButton = FindFirstObjectByType<PortraitHudView>()?.GetComponentInChildren<Button>(true);
if (portraitButton != null)
{
portraitButton.onClick.AddListener(menuController.Open);
}
}
SkillRowView BuildSkillRowPrefab(Transform parent)
{
var root = new GameObject("SkillRow", typeof(RectTransform), typeof(SkillRowView), typeof(HorizontalLayoutGroup));
root.transform.SetParent(parent, false);
var hlg = root.GetComponent<HorizontalLayoutGroup>();
hlg.padding = new RectOffset(8, 8, 4, 4);
hlg.spacing = 12f;
hlg.childAlignment = TextAnchor.MiddleLeft;
hlg.childControlHeight = true;
hlg.childControlWidth = true;
hlg.childForceExpandHeight = true;
var stripe = new GameObject("Accent", typeof(RectTransform), typeof(Image), typeof(LayoutElement));
stripe.transform.SetParent(root.transform, false);
stripe.GetComponent<LayoutElement>().preferredWidth = 6f;
stripe.GetComponent<Image>().color = Color.white;
var name = CreateTmp("Name", root.transform, 24f, FontStyles.Bold);
name.GetComponent<LayoutElement>().flexibleWidth = 1f;
var bar = new GameObject("Bar", typeof(RectTransform), typeof(Image), typeof(LayoutElement));
bar.transform.SetParent(root.transform, false);
bar.GetComponent<Image>().color = new Color(0.2f, 0.2f, 0.22f);
bar.GetComponent<LayoutElement>().preferredWidth = 160f;
bar.GetComponent<LayoutElement>().preferredHeight = 16f;
var fill = new GameObject("Fill", typeof(RectTransform), typeof(Image));
fill.transform.SetParent(bar.transform, false);
Stretch(fill.GetComponent<RectTransform>());
var fillImage = fill.GetComponent<Image>();
fillImage.type = Image.Type.Filled;
fillImage.fillMethod = Image.FillMethod.Horizontal;
fillImage.color = Color.white;
var rank = CreateTmp("Rank", root.transform, 24f, FontStyles.Normal);
rank.alignment = TextAlignmentOptions.Right;
rank.GetComponent<LayoutElement>().preferredWidth = 40f;
var view = root.GetComponent<SkillRowView>();
SetPrivate(view, "nameLabel", name);
SetPrivate(view, "rankLabel", rank);
SetPrivate(view, "barFill", fillImage);
SetPrivate(view, "accentStripe", stripe.GetComponent<Image>());
return view;
}
void EnsureCameraFraming()
{
var vcam = GameObject.Find("CinemachineCamera");
if (vcam == null)
{
return;
}
var framing = vcam.GetComponent<DialogueCameraFraming>();
if (framing == null)
{
framing = vcam.AddComponent<DialogueCameraFraming>();
}
SetPrivate(framing, "composer", vcam.GetComponent<CinemachinePositionComposer>());
SetPrivate(framing, "dialogueScreenX", -0.17f);
}
void WireDialogueRunner()
{
var runner = FindFirstObjectByType<DialogueRunner>();
var stream = FindFirstObjectByType<DialogueStreamPresenter>();
var options = FindFirstObjectByType<DialogueStreamOptionsPresenter>();
var advancer = FindFirstObjectByType<LineAdvancer>();
if (runner == null || stream == null || options == null)
{
return;
}
var list = new System.Collections.Generic.List<DialoguePresenterBase>();
list.Add(stream);
list.Add(options);
if (advancer != null)
{
list.Add(advancer);
SetPrivate(advancer, "presenter", stream);
}
runner.DialoguePresenters = list;
}
void HidePackageDialogueCanvas()
{
var dialogueSystem = GameObject.Find("Dialogue System");
if (dialogueSystem == null)
{
return;
}
var packageCanvas = dialogueSystem.transform.Find("Canvas");
if (packageCanvas == null)
{
return;
}
var banner = packageCanvas.Find("Skill Check Banner");
var streamCanvas = GameObject.Find("DialogueStreamCanvas");
if (banner != null && streamCanvas != null)
{
banner.SetParent(streamCanvas.transform, false);
var bannerRt = banner.GetComponent<RectTransform>();
if (bannerRt != null)
{
bannerRt.anchorMin = new Vector2(0f, 0.85f);
bannerRt.anchorMax = new Vector2(0.66f, 1f);
bannerRt.offsetMin = new Vector2(16f, 0f);
bannerRt.offsetMax = new Vector2(-16f, -16f);
}
}
packageCanvas.gameObject.SetActive(false);
}
static GameObject CreateCanvas(string name, int sortingOrder)
{
var go = new GameObject(name, typeof(RectTransform), typeof(Canvas), typeof(CanvasScaler), typeof(GraphicRaycaster));
var canvas = go.GetComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
canvas.sortingOrder = sortingOrder;
var scaler = go.GetComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1920f, 1080f);
scaler.matchWidthOrHeight = 0.5f;
return go;
}
static GameObject CreateTabPanel(string name, Transform parent)
{
var go = new GameObject(name, typeof(RectTransform), typeof(CanvasGroup));
go.transform.SetParent(parent, false);
Stretch(go.GetComponent<RectTransform>());
var group = go.GetComponent<CanvasGroup>();
group.alpha = 0f;
group.interactable = false;
group.blocksRaycasts = false;
return go;
}
static Button CreateTabButton(string label, Transform parent)
{
var go = new GameObject(label, typeof(RectTransform), typeof(Image), typeof(Button), typeof(LayoutElement));
go.transform.SetParent(parent, false);
go.GetComponent<Image>().color = new Color(0.2f, 0.2f, 0.25f, 1f);
var text = CreateTmp("Label", go.transform, 22f, FontStyles.Bold);
text.text = label;
text.alignment = TextAlignmentOptions.Center;
Stretch(text.rectTransform);
return go.GetComponent<Button>();
}
static TextMeshProUGUI CreateTmp(string name, Transform parent, float size, FontStyles style)
{
var go = new GameObject(name, typeof(RectTransform), typeof(TextMeshProUGUI), typeof(LayoutElement));
go.transform.SetParent(parent, false);
var tmp = go.GetComponent<TextMeshProUGUI>();
tmp.fontSize = size;
tmp.fontStyle = style;
tmp.color = Color.white;
tmp.enableWordWrapping = true;
tmp.text = name;
// No LayoutElement.preferredHeight — it would pin wrapped text to a
// single line's height and make stream rows overlap. See the same
// note in UILayerSetup.CreateTmp.
return tmp;
}
static void Stretch(RectTransform rt, float pad = 0f)
{
rt.anchorMin = Vector2.zero;
rt.anchorMax = Vector2.one;
rt.offsetMin = new Vector2(pad, pad);
rt.offsetMax = new Vector2(-pad, -pad);
}
static void ConfigureVertical(GameObject go)
{
var rt = go.GetComponent<RectTransform>();
rt.anchorMin = new Vector2(0f, 0f);
rt.anchorMax = new Vector2(1f, 0f);
rt.pivot = new Vector2(0.5f, 0f);
// See UILayerSetup.ConfigureVerticalContent: a script-added
// RectTransform starts at sizeDelta (100,100), which overhangs 50px
// off each side under stretch anchors and gets sheared by the mask.
rt.sizeDelta = new Vector2(0f, rt.sizeDelta.y);
rt.anchoredPosition = Vector2.zero;
var vlg = go.GetComponent<VerticalLayoutGroup>();
vlg.childControlHeight = true;
vlg.childControlWidth = true;
vlg.childForceExpandHeight = false;
vlg.childForceExpandWidth = true;
vlg.spacing = 4f;
go.GetComponent<ContentSizeFitter>().verticalFit = ContentSizeFitter.FitMode.PreferredSize;
}
static void SetPrivate(object target, string fieldName, object value)
{
var type = target.GetType();
while (type != null)
{
var field = type.GetField(fieldName, System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Public);
if (field != null)
{
field.SetValue(target, value);
return;
}
type = type.BaseType;
}
Debug.LogWarning($"[UILayerBootstrap] Could not set field '{fieldName}' on {target.GetType().Name}");
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e244f08c4f1b4fe08ddef0a1c5409136
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: -100
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,63 @@
using System;
using UnityEngine;
namespace NightclubArcadia.UI
{
/// <summary>
/// Owns the two independent UI state flags — dialogue active and menu open —
/// and raises <see cref="StateChanged"/> so HUD, camera framing, and panels
/// can derive their visibility from a single source of truth.
/// </summary>
public sealed class UIStateController : MonoBehaviour
{
public static UIStateController Instance { get; private set; }
public bool IsDialogueActive { get; private set; }
public bool IsMenuOpen { get; private set; }
/// <summary>Raised after any state change. Subscribers derive their own visibility.</summary>
public event Action StateChanged;
void Awake()
{
if (Instance != null && Instance != this)
{
Debug.LogError($"[{nameof(UIStateController)}] Duplicate instance on '{name}'. Destroying this component.", this);
Destroy(this);
return;
}
Instance = this;
}
void OnDestroy()
{
if (Instance == this)
{
Instance = null;
}
}
public void SetDialogueActive(bool value)
{
if (IsDialogueActive == value)
{
return;
}
IsDialogueActive = value;
StateChanged?.Invoke();
}
public void SetMenuOpen(bool value)
{
if (IsMenuOpen == value)
{
return;
}
IsMenuOpen = value;
StateChanged?.Invoke();
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: aa16b7ff96b64cd4bd74a00174bf9c60
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: