Files
nightclub-arcadia/NightclubArcadia/Assets/Editor/UILayerSetup.cs
T
lennartandClaude Opus 5 4f48d258b5 disable the pre-split scene builders instead of retargeting them
Fixes the "no Yarn Project has been configured" error coming back after running
the setup menus.

Last commit fixed the wrong layer. Retargeting YarnDemoSetup at a sandbox
stopped it destroying the level, but left the deeper problem: all of these
builders assume the single-scene layout, and after the split there is no target
path that makes them correct.

Two failures, both observed rather than reasoned about. Running the Yarn setup
left a generated scene open whose DialogueRunner had no project and auto-started
— that is the error the user kept seeing, coming from Dev/YarnSandbox.unity, not
from Systems. And retargeting the other three at Systems made PlayerControlSetup
pull navigation in with it; the baked NavMeshData is embedded rather than an
asset, so Systems.unity silently turned binary, 61KB text to 109KB binary. That
is trap 1 recurring on a scene that had just been fixed.

So the three scene builders now return early through LegacySceneSetup.Blocked,
with a message saying what they would have done and what to do instead. They are
not deleted: UILayerSetup alone is 818 lines that still record how the UI layer
is wired, and that record matters until prefabs replace it in Phase 5.

Set Up Skill System keeps its useful half. It regenerates the SkillDefinition
assets from JSON, which touches no scene; only the WireWorkingLevelScene call is
dropped, because those objects already exist in Systems.unity. Set Up Candidate
System was always asset-only and is untouched.

While diagnosing, YarnDemoSetup's own bug was found and fixed even though the
menu is now blocked: it set yarnProject through a SerializedObject on a prefab
instance without recording the override, so the assignment was dropped on save.
It now records the modification and refuses to save a scene whose runner has no
project.

Verified by running all five menus headlessly: the two asset generators run and
produce no diff, the three builders refuse, and no scene or asset changes.
Systems.unity restored to text, the level scene intact at 5 roots.

EditMode 42/42, PlayMode 6/6, YarnCheck 9 files / 35 nodes.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-25 21:24:24 +02:00

822 lines
37 KiB
C#

using System.IO;
using NightclubArcadia.Cinematics;
using NightclubArcadia.Dialogue;
using NightclubArcadia.Skills;
using NightclubArcadia.UI;
using NightclubArcadia.UI.Dialogue;
using NightclubArcadia.UI.HUD;
using NightclubArcadia.UI.Menu;
using StarterAssets;
using TMPro;
using Unity.Cinemachine;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.UI;
using Yarn.Unity;
/// <summary>
/// Builds UI prefabs and wires them into Systems.unity.
/// Run via Tools → Nightclub Arcadia → Set Up UI Layer.
/// </summary>
public static class UILayerSetup
{
const string ScenePath = NightclubArcadia.EditorTools.ScenePaths.Systems;
const string PrefabFolder = "Assets/Prefabs/UI";
const string ArtFolder = "Assets/Art/UI/Placeholder";
// Nested Resources folder: UILayerBootstrap builds the HUD at runtime and
// can only reach the sprite through Resources.Load. Keeping it under
// Art/UI/Placeholder preserves the organisation while making it
// build-safe — an AssetDatabase-only path is null outside the editor.
const string PortraitFolder = ArtFolder + "/Resources";
const string PortraitPath = PortraitFolder + "/portrait_placeholder.png";
const string MessageRowPrefabPath = PrefabFolder + "/DialogueMessageRow.prefab";
const string StreamCanvasPrefabPath = PrefabFolder + "/DialogueStreamCanvas.prefab";
const string HudCanvasPrefabPath = PrefabFolder + "/HudCanvas.prefab";
const string SkillRowPrefabPath = PrefabFolder + "/SkillRow.prefab";
const string MenuCanvasPrefabPath = PrefabFolder + "/PlayerMenuCanvas.prefab";
[MenuItem("Tools/Nightclub Arcadia/Set Up UI Layer")]
public static void SetUp()
{
if (NightclubArcadia.EditorTools.LegacySceneSetup.Blocked("Set Up UI Layer",
"rebuild the UI canvases that already live in Systems.unity, leaving duplicates")) return;
EnsureFolders();
CreatePortraitPlaceholder();
var messageRow = CreateMessageRowPrefab();
var skillRow = CreateSkillRowPrefab();
var streamCanvas = CreateDialogueStreamCanvasPrefab(messageRow);
var hudCanvas = CreateHudCanvasPrefab();
var menuCanvas = CreatePlayerMenuCanvasPrefab(skillRow);
WireScene(streamCanvas, hudCanvas, menuCanvas);
AssetDatabase.SaveAssets();
Debug.Log("[UILayerSetup] UI layer setup complete.");
}
static void EnsureFolders()
{
EnsureFolder("Assets/Prefabs");
EnsureFolder(PrefabFolder);
EnsureFolder("Assets/Art");
EnsureFolder("Assets/Art/UI");
EnsureFolder(ArtFolder);
EnsureFolder(PortraitFolder);
}
static void EnsureFolder(string path)
{
if (AssetDatabase.IsValidFolder(path))
{
return;
}
var parent = Path.GetDirectoryName(path)?.Replace('\\', '/');
var name = Path.GetFileName(path);
if (!string.IsNullOrEmpty(parent) && !string.IsNullOrEmpty(name))
{
AssetDatabase.CreateFolder(parent, name);
}
}
static void CreatePortraitPlaceholder()
{
if (File.Exists(PortraitPath))
{
return;
}
var tex = new Texture2D(512, 512, TextureFormat.RGBA32, false);
var dark = new Color(0.12f, 0.12f, 0.14f, 1f);
var mid = new Color(0.35f, 0.35f, 0.4f, 1f);
for (var y = 0; y < 512; y++)
{
for (var x = 0; x < 512; x++)
{
var dx = (x - 256) / 256f;
var dy = (y - 280) / 256f;
var inHead = dx * dx + dy * dy < 0.12f;
var inBody = Mathf.Abs(dx) < 0.22f && y < 220 && y > 40;
tex.SetPixel(x, y, inHead || inBody ? mid : dark);
}
}
tex.Apply();
File.WriteAllBytes(PortraitPath, tex.EncodeToPNG());
Object.DestroyImmediate(tex);
AssetDatabase.Refresh();
var importer = AssetImporter.GetAtPath(PortraitPath) as TextureImporter;
if (importer != null)
{
importer.textureType = TextureImporterType.Sprite;
importer.spritePixelsPerUnit = 100;
importer.SaveAndReimport();
}
}
static GameObject CreateMessageRowPrefab()
{
var root = new GameObject("DialogueMessageRow", typeof(RectTransform), typeof(DialogueMessageRow));
var rootRt = root.GetComponent<RectTransform>();
rootRt.sizeDelta = new Vector2(0f, 80f);
var layout = root.AddComponent<VerticalLayoutGroup>();
layout.padding = new RectOffset(12, 12, 8, 8);
layout.spacing = 4f;
layout.childControlHeight = true;
layout.childControlWidth = true;
layout.childForceExpandHeight = false;
layout.childForceExpandWidth = true;
var fitter = root.AddComponent<ContentSizeFitter>();
fitter.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
fitter.horizontalFit = ContentSizeFitter.FitMode.Unconstrained;
var speakerGo = CreateTmp("Speaker", root.transform, 22f, FontStyles.Bold, TextAlignmentOptions.Left);
var bodyGo = CreateTmp("Body", root.transform, 28f, FontStyles.Normal, TextAlignmentOptions.TopLeft);
var row = root.GetComponent<DialogueMessageRow>();
var so = new SerializedObject(row);
so.FindProperty("speakerText").objectReferenceValue = speakerGo.GetComponent<TMP_Text>();
so.FindProperty("bodyText").objectReferenceValue = bodyGo.GetComponent<TMP_Text>();
so.FindProperty("speakerLabel").objectReferenceValue = speakerGo;
so.ApplyModifiedPropertiesWithoutUndo();
var prefab = PrefabUtility.SaveAsPrefabAsset(root, MessageRowPrefabPath);
Object.DestroyImmediate(root);
return prefab;
}
static GameObject CreateSkillRowPrefab()
{
var root = new GameObject("SkillRow", typeof(RectTransform), typeof(SkillRowView));
var rootRt = root.GetComponent<RectTransform>();
rootRt.sizeDelta = new Vector2(0f, 48f);
var hlg = root.AddComponent<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;
hlg.childForceExpandWidth = false;
var stripe = CreateUiImage("Accent", root.transform, new Color(1f, 1f, 1f, 1f));
stripe.GetComponent<LayoutElement>().preferredWidth = 6f;
stripe.GetComponent<LayoutElement>().flexibleWidth = 0f;
var nameGo = CreateTmp("Name", root.transform, 24f, FontStyles.Bold, TextAlignmentOptions.Left);
nameGo.GetComponent<LayoutElement>().flexibleWidth = 1f;
var barGo = new GameObject("Bar", typeof(RectTransform), typeof(Image), typeof(LayoutElement));
barGo.transform.SetParent(root.transform, false);
barGo.GetComponent<Image>().color = new Color(0.2f, 0.2f, 0.22f, 1f);
barGo.GetComponent<LayoutElement>().preferredWidth = 160f;
barGo.GetComponent<LayoutElement>().flexibleWidth = 0f;
barGo.GetComponent<LayoutElement>().preferredHeight = 16f;
var fillGo = CreateUiImage("Fill", barGo.transform, Color.white);
var fillRt = fillGo.GetComponent<RectTransform>();
fillRt.anchorMin = Vector2.zero;
fillRt.anchorMax = Vector2.one;
fillRt.offsetMin = Vector2.zero;
fillRt.offsetMax = Vector2.zero;
var fillImage = fillGo.GetComponent<Image>();
fillImage.type = Image.Type.Filled;
fillImage.fillMethod = Image.FillMethod.Horizontal;
fillImage.fillAmount = 0.2f;
Object.DestroyImmediate(fillGo.GetComponent<LayoutElement>());
var rankGo = CreateTmp("Rank", root.transform, 24f, FontStyles.Normal, TextAlignmentOptions.Right);
rankGo.GetComponent<LayoutElement>().preferredWidth = 40f;
rankGo.GetComponent<LayoutElement>().flexibleWidth = 0f;
var view = root.GetComponent<SkillRowView>();
var so = new SerializedObject(view);
so.FindProperty("nameLabel").objectReferenceValue = nameGo.GetComponent<TMP_Text>();
so.FindProperty("rankLabel").objectReferenceValue = rankGo.GetComponent<TMP_Text>();
so.FindProperty("barFill").objectReferenceValue = fillImage;
so.FindProperty("accentStripe").objectReferenceValue = stripe.GetComponent<Image>();
so.ApplyModifiedPropertiesWithoutUndo();
var prefab = PrefabUtility.SaveAsPrefabAsset(root, SkillRowPrefabPath);
Object.DestroyImmediate(root);
return prefab;
}
static GameObject CreateDialogueStreamCanvasPrefab(GameObject messageRowPrefab)
{
var canvasGo = CreateCanvas("DialogueStreamCanvas", 5);
var canvasGroup = canvasGo.AddComponent<CanvasGroup>();
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 = new Vector2(1f, 1f);
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);
contentRt.sizeDelta = new Vector2(0f, 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;
vlg.padding = new RectOffset(4, 4, 8, 8);
var csf = content.GetComponent<ContentSizeFitter>();
csf.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
var messages = new GameObject("Messages", typeof(RectTransform), typeof(VerticalLayoutGroup), typeof(ContentSizeFitter));
messages.transform.SetParent(content.transform, false);
ConfigureVerticalContent(messages);
var options = new GameObject("Options", typeof(RectTransform), typeof(VerticalLayoutGroup), typeof(ContentSizeFitter), typeof(CanvasGroup));
options.transform.SetParent(content.transform, false);
ConfigureVerticalContent(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;
scroll.movementType = ScrollRect.MovementType.Clamped;
var streamPresenter = canvasGo.AddComponent<DialogueStreamPresenter>();
var streamSo = new SerializedObject(streamPresenter);
streamSo.FindProperty("messageRowPrefab").objectReferenceValue = messageRowPrefab.GetComponent<DialogueMessageRow>();
streamSo.FindProperty("messageContainer").objectReferenceValue = messages.GetComponent<RectTransform>();
streamSo.FindProperty("scrollRect").objectReferenceValue = scroll;
streamSo.ApplyModifiedPropertiesWithoutUndo();
var optionPrefab = AssetDatabase.LoadAssetAtPath<GameObject>(
"Packages/dev.yarnspinner.unity/Prefabs/Option Item.prefab");
var optionsPresenter = canvasGo.AddComponent<DialogueStreamOptionsPresenter>();
var optSo = new SerializedObject(optionsPresenter);
if (optionPrefab != null)
{
optSo.FindProperty("optionViewPrefab").objectReferenceValue = optionPrefab.GetComponent<OptionItem>();
}
optSo.FindProperty("optionsContainer").objectReferenceValue = options.transform;
optSo.FindProperty("canvasGroup").objectReferenceValue = optionsGroup;
optSo.ApplyModifiedPropertiesWithoutUndo();
// Start hidden — DialogueTransitionController shows it.
canvasGroup.alpha = 0f;
canvasGroup.interactable = false;
canvasGroup.blocksRaycasts = false;
var prefab = PrefabUtility.SaveAsPrefabAsset(canvasGo, StreamCanvasPrefabPath);
Object.DestroyImmediate(canvasGo);
return prefab;
}
static GameObject CreateHudCanvasPrefab()
{
var canvasGo = CreateCanvas("HudCanvas", 10);
var portraitSprite = AssetDatabase.LoadAssetAtPath<Sprite>(PortraitPath);
var panel = new GameObject("PortraitPanel", typeof(RectTransform), typeof(CanvasGroup), typeof(Image), typeof(Button));
panel.transform.SetParent(canvasGo.transform, false);
var panelRt = panel.GetComponent<RectTransform>();
panelRt.anchorMin = new Vector2(1f, 0f);
panelRt.anchorMax = new Vector2(1f, 0f);
panelRt.pivot = new Vector2(1f, 0f);
panelRt.sizeDelta = new Vector2(220f, 280f);
panelRt.anchoredPosition = new Vector2(-24f, 24f);
panel.GetComponent<Image>().color = new Color(0.15f, 0.15f, 0.18f, 0.95f);
if (portraitSprite != null)
{
panel.GetComponent<Image>().sprite = portraitSprite;
panel.GetComponent<Image>().preserveAspect = true;
}
var hint = CreateTmp("TabHint", panel.transform, 18f, FontStyles.Normal, TextAlignmentOptions.Center);
var hintRt = hint.GetComponent<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);
hint.GetComponent<TMP_Text>().text = "Tab";
Object.DestroyImmediate(hint.GetComponent<LayoutElement>());
var view = canvasGo.AddComponent<PortraitHudView>();
var so = new SerializedObject(view);
so.FindProperty("panelRoot").objectReferenceValue = panel;
so.ApplyModifiedPropertiesWithoutUndo();
var prefab = PrefabUtility.SaveAsPrefabAsset(canvasGo, HudCanvasPrefabPath);
Object.DestroyImmediate(canvasGo);
return prefab;
}
static GameObject CreatePlayerMenuCanvasPrefab(GameObject skillRowPrefab)
{
var canvasGo = CreateCanvas("PlayerMenuCanvas", 20);
var rootGroup = canvasGo.AddComponent<CanvasGroup>();
var dimmer = CreateUiImage("Dimmer", canvasGo.transform, new Color(0f, 0f, 0f, 0.6f));
Stretch(dimmer.GetComponent<RectTransform>());
Object.DestroyImmediate(dimmer.GetComponent<LayoutElement>());
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);
tabBarRt.anchoredPosition = Vector2.zero;
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. Left
// false, each tab button keeps its own 100px sizeDelta ("Character"
// clips to "Charact") and childForceExpandWidth dumps all the leftover
// room between them as a gap instead of widening them.
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 = new Vector2(0f, 0f);
contentRt.anchorMax = new Vector2(1f, 1f);
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 content
var charView = characterPanel.AddComponent<CharacterPanelView>();
var nameLabel = CreateTmp("Name", characterPanel.transform, 36f, FontStyles.Bold, TextAlignmentOptions.Left);
var aliasLabel = CreateTmp("Alias", characterPanel.transform, 28f, FontStyles.Italic, TextAlignmentOptions.Left);
var invLabel = CreateTmp("Inventory", characterPanel.transform, 24f, FontStyles.Normal, TextAlignmentOptions.Left);
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;
charVlg.childForceExpandHeight = false;
var charSo = new SerializedObject(charView);
charSo.FindProperty("nameLabel").objectReferenceValue = nameLabel.GetComponent<TMP_Text>();
charSo.FindProperty("aliasLabel").objectReferenceValue = aliasLabel.GetComponent<TMP_Text>();
charSo.FindProperty("inventoryPlaceholder").objectReferenceValue = invLabel.GetComponent<TMP_Text>();
charSo.ApplyModifiedPropertiesWithoutUndo();
// Skills content
var skillsScroll = new GameObject("SkillsScroll", typeof(RectTransform), typeof(ScrollRect));
skillsScroll.transform.SetParent(skillsPanel.transform, false);
Stretch(skillsScroll.GetComponent<RectTransform>());
var skillsViewport = new GameObject("Viewport", typeof(RectTransform), typeof(RectMask2D), typeof(Image));
skillsViewport.transform.SetParent(skillsScroll.transform, false);
Stretch(skillsViewport.GetComponent<RectTransform>());
skillsViewport.GetComponent<Image>().color = new Color(1f, 1f, 1f, 0.01f);
var skillsContent = new GameObject("Content", typeof(RectTransform), typeof(VerticalLayoutGroup), typeof(ContentSizeFitter));
skillsContent.transform.SetParent(skillsViewport.transform, false);
ConfigureVerticalContent(skillsContent);
var skillsContentRt = skillsContent.GetComponent<RectTransform>();
skillsContentRt.anchorMin = new Vector2(0f, 1f);
skillsContentRt.anchorMax = new Vector2(1f, 1f);
skillsContentRt.pivot = new Vector2(0.5f, 1f);
var skillsScrollRect = skillsScroll.GetComponent<ScrollRect>();
skillsScrollRect.content = skillsContentRt;
skillsScrollRect.viewport = skillsViewport.GetComponent<RectTransform>();
skillsScrollRect.horizontal = false;
var axisHeaderTemplate = CreateTmp("AxisHeaderTemplate", skillsContent.transform, 20f, FontStyles.Bold, TextAlignmentOptions.Left);
axisHeaderTemplate.SetActive(false);
var skillsView = skillsPanel.AddComponent<SkillsPanelView>();
var skillsSo = new SerializedObject(skillsView);
skillsSo.FindProperty("contentRoot").objectReferenceValue = skillsContentRt;
skillsSo.FindProperty("rowPrefab").objectReferenceValue = skillRowPrefab.GetComponent<SkillRowView>();
skillsSo.FindProperty("axisHeaderPrefab").objectReferenceValue = axisHeaderTemplate.GetComponent<TMP_Text>();
skillsSo.FindProperty("displayMaxRank").intValue = 6;
skillsSo.ApplyModifiedPropertiesWithoutUndo();
// Journal content
var journalView = journalPanel.AddComponent<JournalPanelView>();
var journalLabel = CreateTmp("Placeholder", journalPanel.transform, 28f, FontStyles.Italic, TextAlignmentOptions.Center);
Stretch(journalLabel.GetComponent<RectTransform>());
Object.DestroyImmediate(journalLabel.GetComponent<LayoutElement>());
var journalSo = new SerializedObject(journalView);
journalSo.FindProperty("placeholderLabel").objectReferenceValue = journalLabel.GetComponent<TMP_Text>();
journalSo.ApplyModifiedPropertiesWithoutUndo();
var charBtn = CreateTabButton("Character", tabBarGo.transform);
var skillsBtn = CreateTabButton("Skills", tabBarGo.transform);
var journalBtn = CreateTabButton("Journal", tabBarGo.transform);
var tabBar = tabBarGo.GetComponent<MenuTabBar>();
var tabSo = new SerializedObject(tabBar);
var tabsProp = tabSo.FindProperty("tabs");
tabsProp.arraySize = 3;
// Character tab must refresh on show for the same reason as Skills:
// panels toggle via CanvasGroup, so OnEnable never fires again.
SetTabEntry(tabsProp.GetArrayElementAtIndex(0), charBtn.GetComponent<Button>(), characterPanel.GetComponent<CanvasGroup>(), charView, "Refresh");
SetTabEntry(tabsProp.GetArrayElementAtIndex(1), skillsBtn.GetComponent<Button>(), skillsPanel.GetComponent<CanvasGroup>(), skillsView, "Refresh");
SetTabEntry(tabsProp.GetArrayElementAtIndex(2), journalBtn.GetComponent<Button>(), journalPanel.GetComponent<CanvasGroup>(), null);
tabSo.ApplyModifiedPropertiesWithoutUndo();
var menuController = canvasGo.AddComponent<PlayerMenuController>();
var menuSo = new SerializedObject(menuController);
menuSo.FindProperty("menuRoot").objectReferenceValue = canvasGo;
menuSo.FindProperty("tabBar").objectReferenceValue = tabBar;
menuSo.ApplyModifiedPropertiesWithoutUndo();
rootGroup.alpha = 0f;
rootGroup.interactable = false;
rootGroup.blocksRaycasts = false;
var prefab = PrefabUtility.SaveAsPrefabAsset(canvasGo, MenuCanvasPrefabPath);
Object.DestroyImmediate(canvasGo);
return prefab;
}
static void SetTabEntry(SerializedProperty entry, Button button, CanvasGroup panel, Object target, string method = null)
{
entry.FindPropertyRelative("button").objectReferenceValue = button;
entry.FindPropertyRelative("panel").objectReferenceValue = panel;
if (target != null && method != null)
{
var onShown = entry.FindPropertyRelative("onShown");
onShown.FindPropertyRelative("m_PersistentCalls.m_Calls").arraySize = 1;
var call = onShown.FindPropertyRelative("m_PersistentCalls.m_Calls").GetArrayElementAtIndex(0);
call.FindPropertyRelative("m_Target").objectReferenceValue = target;
call.FindPropertyRelative("m_MethodName").stringValue = method;
call.FindPropertyRelative("m_Mode").enumValueIndex = 1; // Void
call.FindPropertyRelative("m_CallState").enumValueIndex = 2; // RuntimeOnly
}
}
static void WireScene(GameObject streamCanvasPrefab, GameObject hudCanvasPrefab, GameObject menuCanvasPrefab)
{
var scene = EditorSceneManager.OpenScene(ScenePath, OpenSceneMode.Single);
// UI System root
var uiSystem = GameObject.Find("UI System");
if (uiSystem == null)
{
uiSystem = new GameObject("UI System");
}
if (uiSystem.GetComponent<UIStateController>() == null)
{
uiSystem.AddComponent<UIStateController>();
}
// Player components
var player = GameObject.Find("PlayerArmature");
if (player == null)
{
Debug.LogError("[UILayerSetup] PlayerArmature not found in scene.");
return;
}
var controlLock = player.GetComponent<PlayerControlLock>();
if (controlLock == null)
{
controlLock = player.AddComponent<PlayerControlLock>();
}
var menuInput = player.GetComponent<PlayerMenuInput>();
if (menuInput == null)
{
menuInput = player.AddComponent<PlayerMenuInput>();
}
var inputs = player.GetComponent<StarterAssetsInputs>();
if (inputs != null)
{
inputs.cursorLocked = false;
EditorUtility.SetDirty(inputs);
}
// DialogueTransitionController
var transition = Object.FindFirstObjectByType<DialogueTransitionController>();
if (transition != null)
{
var tSo = new SerializedObject(transition);
tSo.FindProperty("controlLock").objectReferenceValue = controlLock;
tSo.ApplyModifiedPropertiesWithoutUndo();
}
// Instantiate / refresh UI prefab instances
var streamInstance = EnsurePrefabInstance(streamCanvasPrefab, "DialogueStreamCanvas");
var hudInstance = EnsurePrefabInstance(hudCanvasPrefab, "HudCanvas");
var menuInstance = EnsurePrefabInstance(menuCanvasPrefab, "PlayerMenuCanvas");
// Wire menu controller refs
var menuController = menuInstance.GetComponent<PlayerMenuController>();
if (menuController != null)
{
var mSo = new SerializedObject(menuController);
mSo.FindProperty("menuInput").objectReferenceValue = menuInput;
mSo.FindProperty("controlLock").objectReferenceValue = controlLock;
mSo.ApplyModifiedPropertiesWithoutUndo();
}
// Portrait → Open menu
var portraitButton = hudInstance.GetComponentInChildren<Button>(true);
if (portraitButton != null && menuController != null)
{
while (portraitButton.onClick.GetPersistentEventCount() > 0)
{
UnityEditor.Events.UnityEventTools.RemovePersistentListener(portraitButton.onClick, 0);
}
UnityEditor.Events.UnityEventTools.AddPersistentListener(portraitButton.onClick, menuController.Open);
EditorUtility.SetDirty(portraitButton);
}
// Camera framing
var vcam = GameObject.Find("CinemachineCamera");
if (vcam != null)
{
var framing = vcam.GetComponent<DialogueCameraFraming>();
if (framing == null)
{
framing = vcam.AddComponent<DialogueCameraFraming>();
}
var composer = vcam.GetComponent<CinemachinePositionComposer>();
var fSo = new SerializedObject(framing);
fSo.FindProperty("composer").objectReferenceValue = composer;
fSo.FindProperty("dialogueScreenX").floatValue = -0.17f;
fSo.ApplyModifiedPropertiesWithoutUndo();
}
// Re-point DialogueRunner presenters
var runner = Object.FindFirstObjectByType<DialogueRunner>();
var streamPresenter = streamInstance.GetComponent<DialogueStreamPresenter>();
var optionsPresenter = streamInstance.GetComponent<DialogueStreamOptionsPresenter>();
var lineAdvancer = Object.FindFirstObjectByType<LineAdvancer>();
if (runner != null && streamPresenter != null && optionsPresenter != null)
{
var rSo = new SerializedObject(runner);
var presenters = rSo.FindProperty("dialoguePresenters");
if (presenters != null)
{
presenters.arraySize = lineAdvancer != null ? 3 : 2;
presenters.GetArrayElementAtIndex(0).objectReferenceValue = streamPresenter;
presenters.GetArrayElementAtIndex(1).objectReferenceValue = optionsPresenter;
if (lineAdvancer != null)
{
presenters.GetArrayElementAtIndex(2).objectReferenceValue = lineAdvancer;
}
rSo.ApplyModifiedPropertiesWithoutUndo();
}
}
if (lineAdvancer != null && streamPresenter != null)
{
var aSo = new SerializedObject(lineAdvancer);
aSo.FindProperty("presenter").objectReferenceValue = streamPresenter;
aSo.ApplyModifiedPropertiesWithoutUndo();
}
// Point DialogueTransitionController.dialogueUI at the new canvas
if (transition != null && streamInstance != null)
{
var tSo = new SerializedObject(transition);
tSo.FindProperty("dialogueUI").objectReferenceValue = streamInstance;
tSo.FindProperty("controlLock").objectReferenceValue = controlLock;
tSo.ApplyModifiedPropertiesWithoutUndo();
}
// Deactivate the package Dialogue System Canvas (keep EventSystem alive)
var dialogueSystem = GameObject.Find("Dialogue System");
if (dialogueSystem != null)
{
var packageCanvas = dialogueSystem.transform.Find("Canvas");
if (packageCanvas != null)
{
// Rescue the Skill Check Banner onto the scene-owned Skill Check
// Canvas — NOT into streamInstance. EnsurePrefabInstance
// DestroyImmediates the stream canvas on every re-run, so
// anything parented under it dies with it; that is what left
// SkillRuntime.resultView null after the second setup run.
// Skill Check Canvas is scene-owned, survives re-runs, and its
// sortingOrder 100 already draws above the dialogue canvas.
var banner = packageCanvas.Find("Skill Check Banner");
if (banner != null)
{
var bannerHost = GameObject.Find("Skill Check Canvas");
if (bannerHost != null)
{
banner.SetParent(bannerHost.transform, false);
}
}
packageCanvas.gameObject.SetActive(false);
}
}
// Repair SkillRuntime.resultView. It can be left dangling by an earlier
// run of this menu item, and a null here silently disables every roll
// banner ("SkillCheckResultView is not assigned; skipping roll banner").
// Prefer an active view if more than one survives.
SkillCheckResultView resultView = null;
foreach (var candidate in Object.FindObjectsByType<SkillCheckResultView>(
FindObjectsInactive.Include, FindObjectsSortMode.None))
{
if (resultView == null
|| (!resultView.gameObject.activeInHierarchy && candidate.gameObject.activeInHierarchy))
{
resultView = candidate;
}
}
var skillRuntime = Object.FindFirstObjectByType<SkillRuntime>();
if (skillRuntime != null && resultView != null)
{
var skillSo = new SerializedObject(skillRuntime);
skillSo.FindProperty("resultView").objectReferenceValue = resultView;
skillSo.ApplyModifiedPropertiesWithoutUndo();
}
else if (skillRuntime != null)
{
Debug.LogWarning("[UILayerSetup] No SkillCheckResultView found in the scene; roll banners will not display.", skillRuntime);
}
// Update NPCStandIn dialogueUI refs to the new canvas
foreach (var npc in Object.FindObjectsByType<NPCStandIn>(FindObjectsInactive.Include, FindObjectsSortMode.None))
{
var so = new SerializedObject(npc);
var uiProp = so.FindProperty("dialogueUI");
if (uiProp != null)
{
uiProp.objectReferenceValue = streamInstance;
so.ApplyModifiedPropertiesWithoutUndo();
}
}
EditorSceneManager.MarkSceneDirty(scene);
EditorSceneManager.SaveScene(scene);
}
static GameObject EnsurePrefabInstance(GameObject prefab, string name)
{
var existing = GameObject.Find(name);
if (existing != null)
{
Object.DestroyImmediate(existing);
}
var instance = (GameObject)PrefabUtility.InstantiatePrefab(prefab);
instance.name = name;
return instance;
}
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 GameObject 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, TextAlignmentOptions.Center);
Stretch(text.GetComponent<RectTransform>());
Object.DestroyImmediate(text.GetComponent<LayoutElement>());
text.GetComponent<TMP_Text>().text = label;
return go;
}
static GameObject CreateTmp(string name, Transform parent, float size, FontStyles style, TextAlignmentOptions align)
{
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.alignment = align;
tmp.color = Color.white;
tmp.text = name;
tmp.textWrappingMode = TextWrappingModes.Normal;
// Deliberately no LayoutElement.preferredHeight. A fixed height here is
// one line's worth, and LayoutElement outranks TMP's own ILayoutElement
// report — so a wrapped 4-line body would be allocated ~40px while
// rendering ~150px, and consecutive rows draw on top of each other.
// Leaving it at the -1 default lets TMP measure the wrapped text.
// The component stays so callers can still set preferredWidth /
// flexibleWidth (see the skill row).
return go;
}
static GameObject CreateUiImage(string name, Transform parent, Color color)
{
var go = new GameObject(name, typeof(RectTransform), typeof(Image), typeof(LayoutElement));
go.transform.SetParent(parent, false);
go.GetComponent<Image>().color = color;
return go;
}
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 ConfigureVerticalContent(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);
// A RectTransform added via `new GameObject(..., typeof(RectTransform))`
// starts at sizeDelta (100,100). With horizontal-stretch anchors that
// makes the rect parentWidth + 100 wide, centred — overhanging 50px off
// each side, and the RectMask2D shears the left edge off every label.
// ContentSizeFitter only governs the vertical axis, so it never
// corrects this. Zero the horizontal size explicitly.
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;
var csf = go.GetComponent<ContentSizeFitter>();
csf.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
}
}