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 { /// /// 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. /// 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(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(StringComparer.Ordinal); var changed = new List(); 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(path); if (asset == null) { asset = ScriptableObject.CreateInstance(); 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")); } } } }