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,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);
}
}
}
}
}