Files
nightclub-arcadia/NightclubArcadia/Assets/Editor/SkillSystemSetup.cs
T
lennartandClaude Opus 5 1c1e50809a convert the working scene to text and move it to Scenes/Levels
The scene is now Assets/Scenes/Levels/SC101_ConferenceHall.unity, serialized as
TEXT: 101KB of YAML, diffable and mergeable for the first time.

Root cause of the binary problem, which four earlier approaches had failed to
fix: the NavMeshSurface on the Navigation root held its baked NavMeshData
*embedded in the scene* rather than as an asset. NavMeshData prefers binary
serialization, and one such object forces the whole scene file to binary
regardless of the project's Force Text setting — which is why toggling that
setting, re-saving, and saving to a new path all did nothing.

Bisecting the roots one at a time found it: thirteen roots each saved as text on
their own, Navigation did not. Extracting the data to
Scenes/Levels/SC101_ConferenceHall/NavMesh-Navigation.asset — which is what
baking from the NavMeshSurface inspector produces anyway — lets the scene
serialize as text. The embedded copy was the anomaly.

The scene was rebuilt rather than copied, because saving the binary scene to a
new path reproduces binary. All 14 roots were moved into a fresh scene and
verified: roots 14 -> 14, cross-root references 25 -> 25, zero missing scripts.
Render settings were carried across by hand, since they do not travel with
GameObjects.

Nothing is split yet — every object is still in one scene, so no reference
crosses a scene boundary. That split is the next step.

Also here: the four Editor setup menus no longer each hardcode the scene path;
they share NightclubArcadia.EditorTools.ScenePaths, which is what made renaming
the scene a four-file edit. Build settings point at the new scene, the URP
template leftovers (SampleScene, Readme.asset, TutorialInfo) are deleted, and
.gitattributes drops the binary exception that existed only for the old file.

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

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

353 lines
12 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using NightclubArcadia.Skills;
using TMPro;
using UnityEditor;
using UnityEditor.Callbacks;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.UI;
using Yarn.Unity;
/// <summary>
/// Regenerates SkillDefinition assets + SkillDatabase from the Skill Bible JSON mirror.
///
/// Source of truth for prose/roster: the Obsidian Skill System Draft.md.
/// Checked-in machine mirror: Assets/Skills/skill_bible.json (edit that, not the .assets).
/// Hand-edits to Definitions/*.asset are overwritten on script reload by EnsureSkillAssetsOnly.
/// </summary>
public static class SkillSystemSetup
{
const string DefinitionsFolder = "Assets/Skills/Definitions";
const string SkillsFolder = "Assets/Skills";
const string BiblePath = "Assets/Skills/skill_bible.json";
const string DatabasePath = "Assets/Skills/SkillDatabase.asset";
const string ScenePath = NightclubArcadia.EditorTools.ScenePaths.WorkingLevel;
const string AutoSetupSessionKey = "NightclubArcadia.SkillSystem.AutoSetupDone";
[DidReloadScripts]
static void AutoSetupAfterReload()
{
if (EditorApplication.isPlayingOrWillChangePlaymode)
{
return;
}
if (SessionState.GetBool(AutoSetupSessionKey, false))
{
return;
}
// Defer one frame so AssetDatabase is fully ready after domain reload.
EditorApplication.delayCall += () =>
{
if (SessionState.GetBool(AutoSetupSessionKey, false))
{
return;
}
SessionState.SetBool(AutoSetupSessionKey, true);
EnsureSkillAssetsOnly();
};
}
/// <summary>
/// Creates/updates ScriptableObject assets without opening a scene.
/// Safe to run on domain reload. Scene wiring stays on the menu item /
/// the checked-in SC101_ConferenceHall.unity Skill System objects.
/// </summary>
static void EnsureSkillAssetsOnly()
{
if (!TryLoadRoster(out var roster))
{
return;
}
EnsureFolder("Assets", "Skills");
EnsureFolder("Assets/Skills", "Definitions");
var definitions = new List<SkillDefinition>();
var rosterIds = new HashSet<string>(StringComparer.Ordinal);
foreach (var entry in roster)
{
rosterIds.Add(entry.id);
definitions.Add(CreateOrUpdateDefinition(entry));
}
DeleteOrphanDefinitions(rosterIds);
var database = AssetDatabase.LoadAssetAtPath<SkillDatabase>(DatabasePath);
if (database == null)
{
database = ScriptableObject.CreateInstance<SkillDatabase>();
AssetDatabase.CreateAsset(database, DatabasePath);
}
database.EditorSetSkills(definitions);
EditorUtility.SetDirty(database);
AssetDatabase.SaveAssets();
Debug.Log($"[SkillSystemSetup] Skill assets ready at {DatabasePath}. Run Tools/Nightclub Arcadia/Set Up Skill System if scene refs need rewiring.");
}
[MenuItem("Tools/Nightclub Arcadia/Set Up Skill System")]
public static void SetUpSkillSystem()
{
if (!TryLoadRoster(out var roster))
{
return;
}
EnsureFolder("Assets", "Skills");
EnsureFolder("Assets/Skills", "Definitions");
var definitions = new List<SkillDefinition>();
var rosterIds = new HashSet<string>(StringComparer.Ordinal);
foreach (var entry in roster)
{
rosterIds.Add(entry.id);
definitions.Add(CreateOrUpdateDefinition(entry));
}
DeleteOrphanDefinitions(rosterIds);
var database = AssetDatabase.LoadAssetAtPath<SkillDatabase>(DatabasePath);
if (database == null)
{
database = ScriptableObject.CreateInstance<SkillDatabase>();
AssetDatabase.CreateAsset(database, DatabasePath);
}
database.EditorSetSkills(definitions);
EditorUtility.SetDirty(database);
AssetDatabase.SaveAssets();
WireWorkingLevelScene(database);
AssetDatabase.SaveAssets();
Debug.Log($"Skill system setup complete. Database at {DatabasePath}; scene wired at {ScenePath}.");
}
static bool TryLoadRoster(out SkillBibleEntry[] roster)
{
roster = null;
var text = AssetDatabase.LoadAssetAtPath<TextAsset>(BiblePath);
if (text == null)
{
Debug.LogError($"[SkillSystemSetup] Missing bible at {BiblePath}.");
return false;
}
SkillBibleRoot root;
try
{
root = JsonUtility.FromJson<SkillBibleRoot>(text.text);
}
catch (Exception ex)
{
Debug.LogError($"[SkillSystemSetup] Failed to parse {BiblePath}: {ex.Message}");
return false;
}
if (root?.skills == null || root.skills.Length == 0)
{
Debug.LogError($"[SkillSystemSetup] {BiblePath} has no skills.");
return false;
}
foreach (var entry in root.skills)
{
if (entry == null || string.IsNullOrEmpty(entry.id))
{
Debug.LogError($"[SkillSystemSetup] {BiblePath} contains a skill with empty id.");
return false;
}
// Fail loudly on enum typos — silent defaults are content bugs.
if (!Enum.TryParse<SkillAxis>(entry.axis, ignoreCase: true, out _))
{
Debug.LogError($"[SkillSystemSetup] Skill '{entry.id}': invalid axis '{entry.axis}'.");
return false;
}
if (!Enum.TryParse<TraceChannel>(entry.primaryChannel, ignoreCase: true, out _))
{
Debug.LogError($"[SkillSystemSetup] Skill '{entry.id}': invalid primaryChannel '{entry.primaryChannel}'.");
return false;
}
var secondary = string.IsNullOrEmpty(entry.secondaryChannel) ? "None" : entry.secondaryChannel;
if (!Enum.TryParse<TraceChannel>(secondary, ignoreCase: true, out _))
{
Debug.LogError($"[SkillSystemSetup] Skill '{entry.id}': invalid secondaryChannel '{entry.secondaryChannel}'.");
return false;
}
if (!Enum.TryParse<ClueYield>(entry.clueYield, ignoreCase: true, out _))
{
Debug.LogError($"[SkillSystemSetup] Skill '{entry.id}': invalid clueYield '{entry.clueYield}'.");
return false;
}
}
roster = root.skills;
return true;
}
static SkillDefinition CreateOrUpdateDefinition(SkillBibleEntry entry)
{
var path = $"{DefinitionsFolder}/{entry.id}.asset";
var asset = AssetDatabase.LoadAssetAtPath<SkillDefinition>(path);
if (asset == null)
{
asset = ScriptableObject.CreateInstance<SkillDefinition>();
AssetDatabase.CreateAsset(asset, path);
}
asset.EditorSet(entry);
EditorUtility.SetDirty(asset);
return asset;
}
static void DeleteOrphanDefinitions(HashSet<string> rosterIds)
{
if (!AssetDatabase.IsValidFolder(DefinitionsFolder))
{
return;
}
var guids = AssetDatabase.FindAssets("t:SkillDefinition", new[] { DefinitionsFolder });
foreach (var guid in guids)
{
var path = AssetDatabase.GUIDToAssetPath(guid);
var fileName = Path.GetFileNameWithoutExtension(path);
if (rosterIds.Contains(fileName))
{
continue;
}
Debug.Log($"[SkillSystemSetup] Deleting orphan definition {path}");
AssetDatabase.DeleteAsset(path);
}
}
static void WireWorkingLevelScene(SkillDatabase database)
{
if (!File.Exists(ScenePath))
{
Debug.LogError($"Scene not found at {ScenePath}. Run Tools/Nightclub Arcadia/Set Up Yarn Dialogue Test first.");
return;
}
var scene = EditorSceneManager.OpenScene(ScenePath, OpenSceneMode.Single);
var runner = UnityEngine.Object.FindAnyObjectByType<DialogueRunner>();
if (runner == null)
{
Debug.LogError("No DialogueRunner in SC101_ConferenceHall.unity.");
return;
}
var skillGo = GameObject.Find("Skill System");
if (skillGo == null)
{
skillGo = new GameObject("Skill System");
}
var runtime = skillGo.GetComponent<SkillRuntime>() ?? skillGo.AddComponent<SkillRuntime>();
var commentary = skillGo.GetComponent<CommentarySystem>() ?? skillGo.AddComponent<CommentarySystem>();
var idle = skillGo.GetComponent<CommentaryIdleTimer>() ?? skillGo.AddComponent<CommentaryIdleTimer>();
var banner = EnsureBanner(runner.transform);
var view = banner.GetComponent<SkillCheckResultView>() ?? banner.AddComponent<SkillCheckResultView>();
var runtimeSo = new SerializedObject(runtime);
runtimeSo.FindProperty("database").objectReferenceValue = database;
runtimeSo.FindProperty("dialogueRunner").objectReferenceValue = runner;
runtimeSo.FindProperty("resultView").objectReferenceValue = view;
runtimeSo.FindProperty("commentary").objectReferenceValue = commentary;
runtimeSo.FindProperty("debugContextId").stringValue = "Entrance_Queue";
runtimeSo.ApplyModifiedPropertiesWithoutUndo();
var commentarySo = new SerializedObject(commentary);
commentarySo.FindProperty("dialogueRunner").objectReferenceValue = runner;
commentarySo.FindProperty("database").objectReferenceValue = database;
commentarySo.ApplyModifiedPropertiesWithoutUndo();
var idleSo = new SerializedObject(idle);
idleSo.FindProperty("dialogueRunner").objectReferenceValue = runner;
idleSo.FindProperty("contextId").stringValue = "Entrance_Queue";
idleSo.ApplyModifiedPropertiesWithoutUndo();
var group = banner.GetComponent<CanvasGroup>();
var label = banner.GetComponentInChildren<TMP_Text>(true);
var viewSo = new SerializedObject(view);
viewSo.FindProperty("group").objectReferenceValue = group;
viewSo.FindProperty("label").objectReferenceValue = label;
viewSo.ApplyModifiedPropertiesWithoutUndo();
EditorSceneManager.MarkSceneDirty(scene);
EditorSceneManager.SaveScene(scene);
}
static GameObject EnsureBanner(Transform dialogueRoot)
{
var canvas = dialogueRoot.GetComponentInChildren<Canvas>(true);
if (canvas == null)
{
var canvasGo = new GameObject("Skill Check Canvas", typeof(Canvas), typeof(CanvasScaler), typeof(GraphicRaycaster));
canvas = canvasGo.GetComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
canvasGo.transform.SetParent(dialogueRoot, false);
}
var existing = canvas.transform.Find("Skill Check Banner");
if (existing != null)
{
return existing.gameObject;
}
var banner = new GameObject("Skill Check Banner", typeof(RectTransform), typeof(CanvasGroup), typeof(Image));
banner.transform.SetParent(canvas.transform, false);
var rt = banner.GetComponent<RectTransform>();
rt.anchorMin = new Vector2(0.1f, 0.82f);
rt.anchorMax = new Vector2(0.9f, 0.95f);
rt.offsetMin = Vector2.zero;
rt.offsetMax = Vector2.zero;
var image = banner.GetComponent<Image>();
image.color = new Color(0f, 0f, 0f, 0.75f);
var group = banner.GetComponent<CanvasGroup>();
group.alpha = 0f;
group.blocksRaycasts = false;
group.interactable = false;
var labelGo = new GameObject("Label", typeof(RectTransform));
labelGo.transform.SetParent(banner.transform, false);
var labelRt = labelGo.GetComponent<RectTransform>();
labelRt.anchorMin = Vector2.zero;
labelRt.anchorMax = Vector2.one;
labelRt.offsetMin = new Vector2(16f, 8f);
labelRt.offsetMax = new Vector2(-16f, -8f);
var tmp = labelGo.AddComponent<TextMeshProUGUI>();
tmp.text = string.Empty;
tmp.alignment = TextAlignmentOptions.Center;
tmp.fontSize = 28f;
tmp.textWrappingMode = TextWrappingModes.Normal;
tmp.color = Color.white;
return banner;
}
static void EnsureFolder(string parent, string child)
{
var path = $"{parent}/{child}";
if (!AssetDatabase.IsValidFolder(path))
{
AssetDatabase.CreateFolder(parent, child);
}
}
}