replace the NPC scaffold with data-driven prefabs, and retire the builders

NPCStandIn was written to be dropped on a sphere and carried its identity in
per-instance public fields. The cost of that showed up in the scene itself: of
the two spheres, one was the Bartender and the other was named "Bartender (1)"
in the hierarchy while being configured as the Chair — data changed, name never
was. Nothing outside the scene could see what existed, and two copies could
disagree.

Identity now lives in an InteractableDefinition asset and the scene only places
a prefab. The migration matched the old objects by yarn node rather than by
name, which is the only reason the chair ended up as the chair.

InteractableDefinition rather than the planned "character definition": it covers
a character and a talkable object equally, so Bartender.prefab sits under
Prefabs/Characters and Chair.prefab under Prefabs/Interactables.

Three components collapse into one. DialogueInteractable was in no scene at all
— a dead trigger-zone variant of the same idea. ClickInteractableBridge existed
only to adapt NPCStandIn without modifying it, a constraint that died with
NPCStandIn; DialogueInteractor implements IClickInteractable itself. The parts
worth keeping were lifted out rather than discarded: WorldSpaceLabel and
InteractionPrompt are now normal components on prefab children, so a label can
be seen and positioned in the Editor instead of existing only at runtime.
InputDeviceTracker moved into NightclubArcadia.Core and gained a namespace,
leaving no global-namespace runtime types.

Characters get the same pipeline as Skills and Candidates:
Assets/Characters/characters.json is the source of truth, CharacterSetup
regenerates the definition assets on script reload and removes orphans. Adding
an NPC is a JSON entry plus a prefab — no code, and no Unity for the data half.

Phase 5.4 deletes the one-shot builders rather than disabling them. UILayerSetup,
PlayerControlSetup, UILayerSetupAutoRun and YarnDemoSetup assumed the
single-scene layout, had already destroyed a level scene and turned Systems
binary once each, and after NPCStandIn went they no longer compiled. The prefabs
they used to generate are authored and committed; the wiring knowledge is in
those prefabs and in git history. The asset generators — Skill, Candidate,
Character — are untouched and still work.

EditMode 42/42, PlayMode 7/7, YarnCheck 9 files / 35 nodes. All three scenes
still text.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-08-25 21:53:06 +02:00
co-authored by Claude Opus 5
parent ec4619d9dc
commit 9a0618fd31
54 changed files with 2292 additions and 2195 deletions
@@ -0,0 +1,144 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using NightclubArcadia.Interaction;
using UnityEditor;
using UnityEditor.Callbacks;
using UnityEngine;
namespace NightclubArcadia.EditorTools
{
/// <summary>
/// Regenerates InteractableDefinition assets from Assets/Characters/characters.json.
///
/// The JSON is the source of truth and the .asset files are build output; hand edits
/// to them are overwritten. Same contract as Skills and Candidates, deliberately —
/// a writer adding a character edits one JSON file and never opens Unity.
///
/// This exists because the identity of an NPC used to live in public fields on a
/// scene object, where nothing outside that scene could see it and two copies could
/// disagree. They did: the scene's second "Bartender" was the chair.
/// </summary>
public static class CharacterSetup
{
const string JsonPath = "Assets/Characters/characters.json";
const string DefinitionsFolder = "Assets/Characters/Definitions";
[Serializable]
class Dto
{
public string id;
public string displayName;
public string yarnNode;
public float interactionRange = 3f;
public bool showLabel = true;
}
[Serializable]
class DtoList
{
public Dto[] interactables;
}
[MenuItem("Tools/Nightclub Arcadia/Set Up Characters")]
public static void SetUpCharacters() => Regenerate(verbose: true);
[DidReloadScripts]
static void OnScriptsReloaded() => Regenerate(verbose: false);
static void Regenerate(bool verbose)
{
if (!File.Exists(JsonPath))
{
if (verbose)
{
Debug.LogError($"[Characters] Missing {JsonPath}");
}
return;
}
DtoList parsed;
try
{
parsed = JsonUtility.FromJson<DtoList>(File.ReadAllText(JsonPath));
}
catch (Exception e)
{
Debug.LogError($"[Characters] {JsonPath} is not valid JSON: {e.Message}");
return;
}
if (parsed?.interactables == null || parsed.interactables.Length == 0)
{
Debug.LogError($"[Characters] {JsonPath} defines no interactables.");
return;
}
var duplicates = parsed.interactables.GroupBy(i => i.id).Where(g => g.Count() > 1).ToList();
if (duplicates.Count > 0)
{
Debug.LogError("[Characters] Duplicate ids: " +
string.Join(", ", duplicates.Select(g => g.Key)));
return;
}
if (!AssetDatabase.IsValidFolder(DefinitionsFolder))
{
AssetDatabase.CreateFolder("Assets/Characters", "Definitions");
}
var live = new HashSet<string>(StringComparer.Ordinal);
var changed = new List<string>();
foreach (var dto in parsed.interactables)
{
if (string.IsNullOrWhiteSpace(dto.id))
{
Debug.LogError("[Characters] An entry has no id; skipped.");
continue;
}
live.Add(dto.id);
var path = $"{DefinitionsFolder}/{dto.id}.asset";
var asset = AssetDatabase.LoadAssetAtPath<InteractableDefinition>(path);
if (asset == null)
{
asset = ScriptableObject.CreateInstance<InteractableDefinition>();
AssetDatabase.CreateAsset(asset, path);
changed.Add(dto.id);
}
else if (asset.DisplayName != dto.displayName || asset.YarnNode != dto.yarnNode ||
!Mathf.Approximately(asset.InteractionRange, dto.interactionRange) ||
asset.ShowLabel != dto.showLabel)
{
changed.Add(dto.id);
}
asset.EditorSet(dto.id, dto.displayName, dto.yarnNode, dto.interactionRange, dto.showLabel);
EditorUtility.SetDirty(asset);
}
// An id removed from the JSON should not leave an orphan asset behind that a
// prefab could still be pointing at.
foreach (var guid in AssetDatabase.FindAssets("t:InteractableDefinition", new[] { DefinitionsFolder }))
{
var path = AssetDatabase.GUIDToAssetPath(guid);
var name = Path.GetFileNameWithoutExtension(path);
if (!live.Contains(name))
{
Debug.LogWarning($"[Characters] Removing orphan definition '{name}' — no longer in the JSON.");
AssetDatabase.DeleteAsset(path);
}
}
AssetDatabase.SaveAssets();
if (verbose || changed.Count > 0)
{
Debug.Log($"[Characters] {parsed.interactables.Length} definition(s) in {DefinitionsFolder}" +
(changed.Count > 0 ? $"; updated: {string.Join(", ", changed)}" : "; no changes"));
}
}
}
}
@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: 5bdf044f1ab04312b76895dac1740b37
guid: 9cbb2b742dcc4031a4e0cfc0c0544faf
MonoImporter:
externalObjects: {}
serializedVersion: 2
@@ -1,48 +0,0 @@
using UnityEditor;
using UnityEngine;
namespace NightclubArcadia.EditorTools
{
/// <summary>
/// Gate for the one-shot scene builders under Assets/Editor.
///
/// Those menus were written when the whole game lived in a single scene. They find
/// or create the dialogue system, the UI canvases, the player and the camera rig,
/// and save the result into one scene. After the split into Bootstrap / Systems /
/// Level, that assumption is false, and running them does damage rather than work:
///
/// - Pointed at a level, they rebuild systems content that already exists in
/// Systems.unity, leaving duplicate UIStateControllers and dialogue runners.
/// - Pointed at Systems, PlayerControlSetup drags navigation in with it. The
/// baked NavMeshData is embedded rather than an asset, and NavMeshData prefers
/// binary serialization, so the whole scene silently turns binary — the exact
/// problem that took a bisection to find the first time (CLAUDE.md trap 1).
///
/// Both were observed, not theorised. So the builders are blocked rather than
/// retargeted, and deliberately not deleted: UILayerSetup alone is 818 lines that
/// still record how the UI layer is wired, and that record is worth keeping until
/// the prefabs replace it (docs/restructure-plan.md Phase 5).
///
/// Asset generators are unaffected. Regenerating SkillDefinitions or the candidate
/// registry from JSON touches no scene and stays available.
/// </summary>
public static class LegacySceneSetup
{
/// <summary>
/// Call at the top of any menu that builds scene content. Returns true if the
/// caller should stop, which is currently always.
/// </summary>
public static bool Blocked(string menuName, string whatItWouldDo)
{
Debug.LogError(
$"'{menuName}' is disabled.\n\n" +
$"It was written for the single-scene layout and would {whatItWouldDo}. " +
"The game is now split across Bootstrap, Systems and a level scene, and the " +
"objects it builds already exist in Assets/Scenes/Systems/Systems.unity.\n\n" +
"To run the game: open Assets/Scenes/Bootstrap.unity and press Play.\n" +
"To verify it comes up: make test-play\n" +
"Rewriting these for the split is docs/restructure-plan.md Phase 5.");
return true;
}
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 6d28eed724b04f52af81ac74d57f3f26
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,320 +0,0 @@
using NightclubArcadia.Cinematics;
using NightclubArcadia.Interaction;
using NightclubArcadia.Player;
using StarterAssets;
using Unity.AI.Navigation;
using Unity.Cinemachine;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.AI;
/// <summary>
/// Wires player control, navigation, and camera systems into Systems.unity.
/// Run via Tools → Nightclub Arcadia → Set Up Player Control &amp; Camera.
/// </summary>
public static class PlayerControlSetup
{
const string ScenePath = NightclubArcadia.EditorTools.ScenePaths.Systems;
const string PlayerPrefabPath = "Assets/StarterAssets/ThirdPersonController/Prefabs/PlayerArmature.prefab";
const string SettingsFolder = "Assets/Settings";
[MenuItem("Tools/Nightclub Arcadia/Set Up Player Control & Camera")]
public static void SetUp()
{
if (NightclubArcadia.EditorTools.LegacySceneSetup.Blocked("Set Up Player Control & Camera",
"rebuild the player and camera rig and pull navigation into Systems.unity, "
+ "which turns that scene binary")) return;
EnsurePlayerPrefab();
WireScene();
AssetDatabase.SaveAssets();
Debug.Log("[PlayerControlSetup] Player control and camera setup complete.");
}
static void EnsurePlayerPrefab()
{
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(PlayerPrefabPath);
if (prefab == null)
{
Debug.LogError($"[PlayerControlSetup] Missing prefab at {PlayerPrefabPath}");
return;
}
var root = PrefabUtility.LoadPrefabContents(PlayerPrefabPath);
EnsureComponent<PlayerPointerInput>(root);
EnsureComponent<PlayerMotionArbiter>(root);
EnsureComponent<ClickToMoveController>(root);
EnsureComponent<PointerNavigationInput>(root);
EnsureComponent<NavMeshAgent>(root);
var inputs = root.GetComponent<StarterAssetsInputs>();
if (inputs != null)
{
inputs.analogMovement = true;
inputs.cursorLocked = false;
}
var controller = root.GetComponent<ThirdPersonController>();
if (controller != null)
{
controller.LockCameraPosition = true;
}
var clickToMove = root.GetComponent<ClickToMoveController>();
var pointerNav = root.GetComponent<PointerNavigationInput>();
var pointerInput = root.GetComponent<PlayerPointerInput>();
var arbiter = root.GetComponent<PlayerMotionArbiter>();
WireSerializedReference(clickToMove, "brainCamera", null);
WireSerializedReference(pointerNav, "pointerInput", pointerInput);
WireSerializedReference(pointerNav, "clickToMove", clickToMove);
WireSerializedReference(pointerNav, "motionArbiter", arbiter);
PrefabUtility.SaveAsPrefabAsset(root, PlayerPrefabPath);
PrefabUtility.UnloadPrefabContents(root);
}
static void WireScene()
{
var scene = EditorSceneManager.OpenScene(ScenePath, OpenSceneMode.Single);
var player = GameObject.FindGameObjectWithTag("Player");
if (player == null)
{
Debug.LogError("[PlayerControlSetup] No Player-tagged object found in scene.");
return;
}
EnsureComponent<PlayerPointerInput>(player);
EnsureComponent<PlayerMotionArbiter>(player);
EnsureComponent<ClickToMoveController>(player);
EnsureComponent<PointerNavigationInput>(player);
EnsureComponent<NavMeshAgent>(player);
var inputs = player.GetComponent<StarterAssetsInputs>();
if (inputs != null)
{
inputs.analogMovement = true;
inputs.cursorLocked = false;
}
var controller = player.GetComponent<ThirdPersonController>();
if (controller != null)
{
controller.LockCameraPosition = true;
}
var brainCamera = Camera.main;
var pointerInput = player.GetComponent<PlayerPointerInput>();
var clickToMove = player.GetComponent<ClickToMoveController>();
var pointerNav = player.GetComponent<PointerNavigationInput>();
var arbiter = player.GetComponent<PlayerMotionArbiter>();
WireSerializedReference(clickToMove, "brainCamera", brainCamera);
WireSerializedReference(pointerNav, "brainCamera", brainCamera);
WireSerializedReference(pointerNav, "pointerInput", pointerInput);
WireSerializedReference(pointerNav, "clickToMove", clickToMove);
WireSerializedReference(pointerNav, "motionArbiter", arbiter);
var groundLayer = LayerMask.NameToLayer("Ground");
var interactableLayer = LayerMask.NameToLayer("Interactable");
SetLayerIfExists("Floor", groundLayer);
SetLayerIfExists("Room_101", groundLayer);
SetLayerIfExists("Wall_Back", groundLayer);
SetLayerIfExists("Wall_Front", groundLayer);
SetLayerIfExists("Table", groundLayer);
WireBartender("Bartender", interactableLayer);
WireBartender("Bartender (1)", interactableLayer);
EnsureNavigation(player.transform.position);
EnsureCameraAnchor(player.transform, brainCamera);
EditorSceneManager.MarkSceneDirty(scene);
EditorSceneManager.SaveScene(scene);
}
static void WireBartender(string objectName, int interactableLayer)
{
var bartender = GameObject.Find(objectName);
if (bartender == null)
{
return;
}
if (interactableLayer >= 0)
{
bartender.layer = interactableLayer;
}
EnsureComponent<ClickInteractableBridge>(bartender);
EnsureComponent<CameraPointOfInterest>(bartender);
}
static void EnsureNavigation(Vector3 sampleOrigin)
{
var navigation = GameObject.Find("Navigation");
if (navigation == null)
{
navigation = new GameObject("Navigation");
}
var surface = EnsureComponent<NavMeshSurface>(navigation);
surface.agentTypeID = 0;
surface.collectObjects = CollectObjects.All;
surface.useGeometry = NavMeshCollectGeometry.RenderMeshes;
var included = 0;
var defaultLayer = LayerMask.NameToLayer("Default");
var groundLayer = LayerMask.NameToLayer("Ground");
if (defaultLayer >= 0)
{
included |= 1 << defaultLayer;
}
if (groundLayer >= 0)
{
included |= 1 << groundLayer;
}
surface.layerMask = included;
var table = GameObject.Find("Table");
if (table != null)
{
var modifier = EnsureComponent<NavMeshModifier>(table);
modifier.overrideArea = true;
modifier.area = NavMesh.GetAreaFromName("Not Walkable");
}
surface.BuildNavMesh();
if (!NavMesh.SamplePosition(sampleOrigin, out _, 2f, NavMesh.AllAreas))
{
Debug.LogWarning("[PlayerControlSetup] NavMesh bake did not cover the player start position.");
}
}
static void EnsureCameraAnchor(Transform followTarget, Camera brainCamera)
{
var vcamObject = GameObject.Find("CinemachineCamera");
if (vcamObject == null)
{
Debug.LogError("[PlayerControlSetup] CinemachineCamera not found.");
return;
}
var vcam = vcamObject.GetComponent<CinemachineCamera>();
var composer = vcamObject.GetComponent<CinemachinePositionComposer>();
var anchorObject = GameObject.Find("CameraAnchor");
if (anchorObject == null)
{
anchorObject = new GameObject("CameraAnchor");
}
anchorObject.transform.position = followTarget.position;
var anchor = EnsureComponent<CameraRigAnchor>(anchorObject);
EnsureComponent<CameraPanInput>(anchorObject);
EnsureComponent<CameraPointOfInterestDirector>(anchorObject);
EnsureComponent<CameraStateDirector>(anchorObject);
WireSerializedReference(anchor, "followTarget", followTarget);
WireSerializedReference(anchor, "composer", composer);
if (vcam != null)
{
vcam.Target.TrackingTarget = anchorObject.transform;
vcam.Priority.Value = 10;
}
var pan = anchorObject.GetComponent<CameraPanInput>();
WireSerializedReference(pan, "brainCamera", brainCamera);
WireSerializedReference(pan, "anchor", anchor);
WireSerializedReference(pan, "stateDirector", anchorObject.GetComponent<CameraStateDirector>());
var poiDirector = anchorObject.GetComponent<CameraPointOfInterestDirector>();
WireSerializedReference(poiDirector, "anchor", anchor);
WireSerializedReference(poiDirector, "stateDirector", anchorObject.GetComponent<CameraStateDirector>());
var stateDirector = anchorObject.GetComponent<CameraStateDirector>();
WireSerializedReference(stateDirector, "anchor", anchor);
EnsureRevealCamera(vcamObject, followTarget);
}
static void EnsureRevealCamera(GameObject followVcam, Transform followTarget)
{
var revealName = "CM_Reveal_Room101";
var revealObject = GameObject.Find(revealName);
if (revealObject == null)
{
revealObject = new GameObject(revealName);
revealObject.transform.SetPositionAndRotation(
followVcam.transform.position + new Vector3(-4f, 2f, -2f),
followVcam.transform.rotation);
}
var revealCam = EnsureComponent<CinemachineCamera>(revealObject);
revealCam.Target.TrackingTarget = followTarget;
revealCam.Priority.Value = 0;
revealCam.Lens.FieldOfView = followVcam.GetComponent<CinemachineCamera>().Lens.FieldOfView;
var volumeObject = GameObject.Find("RevealVolume_Room101");
if (volumeObject == null)
{
volumeObject = new GameObject("RevealVolume_Room101");
volumeObject.transform.position = followTarget.position + new Vector3(0f, 1f, 2f);
}
var box = EnsureComponent<BoxCollider>(volumeObject);
box.isTrigger = true;
box.size = new Vector3(6f, 3f, 4f);
var volume = EnsureComponent<CameraFramingVolume>(volumeObject);
WireSerializedReference(volume, "revealCamera", revealCam);
}
static T EnsureComponent<T>(GameObject target) where T : Component
{
var component = target.GetComponent<T>();
if (component == null)
{
component = target.AddComponent<T>();
}
return component;
}
static void SetLayerIfExists(string objectName, int layer)
{
if (layer < 0)
{
return;
}
var obj = GameObject.Find(objectName);
if (obj != null)
{
obj.layer = layer;
}
}
static void WireSerializedReference(Object target, string propertyName, Object value)
{
if (target == null)
{
return;
}
var so = new SerializedObject(target);
var property = so.FindProperty(propertyName);
if (property != null)
{
property.objectReferenceValue = value;
so.ApplyModifiedPropertiesWithoutUndo();
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: eedfabcda58c94099b2ad3e19f141b09
+1 -23
View File
@@ -15,30 +15,8 @@ namespace NightclubArcadia.EditorTools
/// <summary>Persistent systems: dialogue, skills, UI, camera rig, player.</summary>
public const string Systems = "Assets/Scenes/Systems/Systems.unity";
/// <summary>
/// The scene the setup menus wire into. SC-101's conference hall.
///
/// Only for menus that OPEN and MODIFY a scene. A menu that builds a scene from
/// scratch and saves over its target must never point here — see Sandbox.
/// </summary>
/// <summary>The level scene. SC-101's conference hall.</summary>
public const string WorkingLevel = "Assets/Scenes/Levels/SC101_ConferenceHall.unity";
/// <summary>
/// Scratch scene for menus that generate a scene wholesale.
///
/// This exists because pointing such a menu at WorkingLevel destroyed it once:
/// YarnDemoSetup builds a demo scene and calls SaveScene over its target, so
/// running it replaced SC-101 with a bare Yarn demo. Anything under Scenes/Dev
/// is disposable by definition; nothing outside it is.
/// </summary>
public const string Sandbox = "Assets/Scenes/Dev/YarnSandbox.unity";
/// <summary>
/// Guard for any menu that overwrites a whole scene. Generated scenes belong in
/// Scenes/Dev and nowhere else.
/// </summary>
public static bool IsDisposable(string scenePath) =>
!string.IsNullOrEmpty(scenePath) &&
scenePath.Replace('\\', '/').StartsWith("Assets/Scenes/Dev/", System.StringComparison.Ordinal);
}
}
@@ -1,821 +0,0 @@
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;
}
}
@@ -1,48 +0,0 @@
using UnityEditor;
using UnityEngine;
/// <summary>
/// Reminds the user to run the one-shot prefab/scene wiring menu after scripts
/// compile. Runtime play works without it via UILayerBootstrap.
/// </summary>
[InitializeOnLoad]
static class UILayerSetupAutoRun
{
const string PrefKey = "NightclubArcadia.UILayerSetup.Reminded";
const string MarkerPrefab = "Assets/Prefabs/UI/PlayerMenuCanvas.prefab";
static UILayerSetupAutoRun()
{
EditorApplication.delayCall += RemindIfNeeded;
}
static void RemindIfNeeded()
{
if (EditorApplication.isPlayingOrWillChangePlaymode || EditorApplication.isCompiling)
{
return;
}
if (AssetDatabase.LoadAssetAtPath<GameObject>(MarkerPrefab) != null)
{
return;
}
if (EditorPrefs.GetBool(PrefKey, false))
{
return;
}
EditorPrefs.SetBool(PrefKey, true);
Debug.Log(
"[UILayer] Prefabs not built yet. Play Mode works via UILayerBootstrap. " +
"To author prefabs into the scene, run Tools → Nightclub Arcadia → Set Up UI Layer.");
}
[MenuItem("Tools/Nightclub Arcadia/Reset UI Layer Auto-Setup Flag")]
static void ResetFlag()
{
EditorPrefs.DeleteKey(PrefKey);
Debug.Log("[UILayer] Reminder flag cleared.");
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 6c0e3c886ff143d3b26b7f55d7f22410
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,120 +0,0 @@
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.EventSystems;
using Yarn.Compiler;
using Yarn.Unity;
using Yarn.Unity.Editor;
public static class YarnDemoSetup
{
const string YarnProjectPath = "Assets/Dialogue/NightclubArcadia.yarnproject";
const string ScenePath = NightclubArcadia.EditorTools.ScenePaths.Sandbox;
[MenuItem("Tools/Nightclub Arcadia/Set Up Yarn Sandbox Scene")]
public static void SetUpYarnDialogueTest()
{
if (NightclubArcadia.EditorTools.LegacySceneSetup.Blocked("Set Up Yarn Sandbox Scene",
"generate a throwaway scene whose dialogue runner duplicates the real one")) return;
// This menu BUILDS A SCENE FROM SCRATCH and saves over ScenePath. Pointed at a
// real level it silently destroys it, which is exactly what happened when it
// was briefly retargeted at SC101_ConferenceHall. The guard is the fix; do not
// remove it, and do not point ScenePath outside Scenes/Dev.
if (!NightclubArcadia.EditorTools.ScenePaths.IsDisposable(ScenePath))
{
Debug.LogError($"Refusing to run: '{ScenePath}' is not under Assets/Scenes/Dev/. " +
"This menu overwrites its target scene wholesale.");
return;
}
if (!AssetDatabase.IsValidFolder("Assets/Scenes/Dev"))
{
AssetDatabase.CreateFolder("Assets/Scenes", "Dev");
}
if (!AssetDatabase.IsValidFolder("Assets/TextMesh Pro"))
{
Debug.LogWarning("TextMesh Pro essential resources are missing. Run Window → TextMeshPro → Import TMP Essential Resources.");
}
if (!AssetDatabase.IsValidFolder("Assets/Dialogue"))
{
AssetDatabase.CreateFolder("Assets", "Dialogue");
}
if (!AssetDatabase.IsValidFolder("Assets/Scenes"))
{
AssetDatabase.CreateFolder("Assets", "Scenes");
}
var yarnProject = AssetDatabase.LoadAssetAtPath<YarnProject>(YarnProjectPath);
if (yarnProject == null)
{
// YarnProjectUtility.CreateDefaultYarnProject is internal, so
// construct the same default project here.
var project = new Project
{
ExcludeFilePatterns = new[] { "**/*~/*", "./Samples/Yarn Spinner*/*" },
};
YarnEditorUtility.CreateYarnProject(YarnProjectPath, project);
AssetDatabase.Refresh();
yarnProject = AssetDatabase.LoadAssetAtPath<YarnProject>(YarnProjectPath);
}
var scene = EditorSceneManager.NewScene(NewSceneSetup.DefaultGameObjects, NewSceneMode.Single);
var runner = CreateDialogueRunnerMenuItem.CreateDialogueRunner();
var so = new SerializedObject(runner);
so.FindProperty("yarnProject").objectReferenceValue = yarnProject;
so.ApplyModifiedPropertiesWithoutUndo();
// CreateDialogueRunner returns a component on a PREFAB INSTANCE of the package's
// Dialogue System prefab. Applying a SerializedProperty on one of those does not
// always record the override, and an unrecorded override is dropped on save —
// which produced a sandbox whose runner had no project, auto-started, and
// failed with "Can't start dialogue: no Yarn Project has been configured."
if (PrefabUtility.IsPartOfPrefabInstance(runner))
{
PrefabUtility.RecordPrefabInstancePropertyModifications(runner);
}
runner.autoStart = true;
runner.startNode = "Start";
EditorUtility.SetDirty(runner);
EditorSceneManager.MarkSceneDirty(scene);
// Postcondition. This menu exists to produce a runnable scene; a scene whose
// runner has no project is worse than no scene, because it fails at Play time
// with an error that points nowhere near here.
if (new SerializedObject(runner).FindProperty("yarnProject").objectReferenceValue == null)
{
Debug.LogError("Yarn sandbox setup failed: the DialogueRunner has no Yarn Project. " +
"The generated scene will error on Play. Not saving.");
return;
}
#if UNITY_2023_1_OR_NEWER
var eventSystems = Object.FindObjectsByType<EventSystem>(FindObjectsInactive.Include);
#else
var eventSystems = Object.FindObjectsOfType<EventSystem>(true);
#endif
foreach (var eventSystem in eventSystems)
{
if (eventSystem.GetComponent<BaseInputModule>() != null)
{
continue;
}
#if ENABLE_INPUT_SYSTEM
eventSystem.gameObject.AddComponent<UnityEngine.InputSystem.UI.InputSystemUIInputModule>();
#endif
}
EditorSceneManager.SaveScene(scene, ScenePath);
AssetDatabase.SaveAssets();
Debug.Log($"Yarn dialogue test setup complete. Scene saved to {ScenePath}");
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 6d9af60546b8c47c892a7b924fd4c91e