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:
@@ -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:
|
||||
Reference in New Issue
Block a user