using System.Collections.Generic; using UnityEditor; using UnityEditor.SceneManagement; using UnityEngine; using UnityEngine.SceneManagement; using Ashwild.Network; namespace Ashwild.EditorTools { /// /// The one menu item that keeps the baked ids of scene WorldObjects — Pickables and Harvestables /// alike — correct. The id is the object's identity across the network: every client loads the same /// scene, so the same id must designate the same object on every machine. /// /// It does the whole job in one click, in the order the three steps have to run: /// strip the ids that ended up baked in prefab assets, give an id to every scene object that /// lacks a valid one, then verify the result and report it. Splitting them into separate menu items /// only creates ways to run them in the wrong order, or to forget one. /// /// Assignment is stable and additive: ids already baked into the scene are kept, and only the /// objects that need one (never assigned, or colliding with another object) are given the next free /// number. An earlier version renumbered everything from zero on each run, ordered by /// InstanceID — an order that is not stable between editor sessions — so a single new bush /// reshuffled every id in the scene and produced an unreviewable diff. /// /// Ids belong to a scene, never to a prefab asset: an id baked into a prefab is inherited by every /// instance placed from it, so all of them collide on that one id from the moment they are created — /// which reads, in game, as objects that play their interaction and give nothing. /// public static class WorldObjectIdAssigner { #region Constants private const string LogPrefix = "[WorldObjectIds]"; /// /// How many fixed objects are named in the summary before it collapses into a count — a freshly /// scattered zone can need hundreds of ids, and an unreadable console helps nobody. /// private const int MaxNamesInSummary = 8; #endregion #region Menu /// /// Clears prefab-baked ids, assigns an id to every scene WorldObject that lacks a valid one, then /// validates the outcome. Reports what changed and, if anything is still wrong, logs each culprit /// with its object as context so clicking the message selects it in the hierarchy. /// [MenuItem("Tools/Ashwild/Setup World Object IDs")] public static void Setup() { if (PrefabStageUtility.GetCurrentPrefabStage() != null) { Debug.LogError($"{LogPrefix} Ids are per scene, not per prefab. Close the prefab stage and run this " + "from the scene that contains the objects."); return; } int clearedPrefabs = ClearPrefabIds(); if (!TryCollect(out List objects)) return; List assigned = AssignMissingIds(objects); if (assigned.Count > 0) MarkScenesDirty(objects); Report(objects, clearedPrefabs, assigned); Validate(objects); } #endregion #region Steps /// /// Strips the id baked into WorldObject prefab assets, so newly placed instances start /// unassigned instead of all inheriting the same id. Scene instances keep their own id, which is /// stored as a prefab override and left untouched — but an instance that had no override now reads /// as unassigned, which is exactly why the assignment step has to run after this one. /// Returns how many prefabs were cleaned. /// private static int ClearPrefabIds() { string[] guids = AssetDatabase.FindAssets("t:Prefab"); int cleared = 0; foreach (string guid in guids) { GameObject prefab = AssetDatabase.LoadAssetAtPath(AssetDatabase.GUIDToAssetPath(guid)); if (prefab == null) continue; bool changed = false; foreach (WorldObject obj in prefab.GetComponentsInChildren(true)) { if (obj.Id < 0) continue; WriteId(obj, -1); changed = true; } if (!changed) continue; EditorUtility.SetDirty(prefab); cleared++; } if (cleared > 0) AssetDatabase.SaveAssets(); return cleared; } /// /// Gives the next free id to every object that has none or that duplicates an id already taken, /// leaving valid ids untouched. Returns the objects that were changed. /// private static List AssignMissingIds(List objects) { HashSet taken = new HashSet(); List assigned = new List(); int next = 0; foreach (WorldObject obj in objects) { if (obj.Id >= 0 && taken.Add(obj.Id)) continue; while (!taken.Add(next)) next++; WriteId(obj, next); assigned.Add(obj); } return assigned; } /// /// Re-checks the ids after the fact and logs every remaining problem against its object. Nothing /// should ever be reported here — it is the proof that the run actually worked, rather than a /// summary claiming it did. /// private static void Validate(List objects) { Dictionary byId = new Dictionary(); int problems = 0; foreach (WorldObject obj in objects) { if (obj.Id < 0) { problems++; Debug.LogError($"{LogPrefix} '{GetPath(obj)}' still has no id — it will not be interactable in game.", obj); continue; } if (byId.TryGetValue(obj.Id, out WorldObject other)) { problems++; Debug.LogError($"{LogPrefix} '{GetPath(obj)}' still shares id {obj.Id} with '{GetPath(other)}' — " + "using one silently disables the other.", obj); continue; } byId.Add(obj.Id, obj); } if (problems > 0) Debug.LogError($"{LogPrefix} {problems} id problem(s) survived the setup — this should not happen; " + "check the errors above."); } /// /// Logs what the run changed, in one line, and reminds to save when the scene was touched. /// private static void Report(List objects, int clearedPrefabs, List assigned) { string prefabs = clearedPrefabs > 0 ? $"Cleared the baked id of {clearedPrefabs} prefab(s). " : string.Empty; if (assigned.Count == 0) { Debug.Log($"{LogPrefix} {prefabs}All {objects.Count} scene WorldObject(s) already have a unique id — nothing to assign."); return; } Debug.Log($"{LogPrefix} {prefabs}Assigned an id to {assigned.Count} of {objects.Count} scene WorldObject(s): " + $"{Describe(assigned)}. Save the scene to bake them."); } #endregion #region Internal Helpers /// /// Gathers the WorldObjects of the open scenes, in a deterministic order (scene, then hierarchy /// path) so two runs walk them the same way and hand out the same ids. /// private static bool TryCollect(out List objects) { objects = null; WorldObject[] found = Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None); if (found.Length == 0) { Debug.Log($"{LogPrefix} No WorldObject found in the open scene(s)."); return false; } objects = new List(found); objects.Sort((a, b) => { int byScene = string.CompareOrdinal(a.gameObject.scene.path, b.gameObject.scene.path); return byScene != 0 ? byScene : string.CompareOrdinal(GetPath(a), GetPath(b)); }); return true; } /// /// Writes the id through SerializedObject so the change is recorded as a prefab override on an /// instance, rather than silently editing the shared prefab value. /// private static void WriteId(WorldObject obj, int id) { SerializedObject so = new SerializedObject(obj); so.FindProperty("id").intValue = id; so.ApplyModifiedPropertiesWithoutUndo(); EditorUtility.SetDirty(obj); } /// /// Marks every scene that owns one of these objects dirty, since the open scenes may be several /// (additive loading) and only the touched ones need saving. /// private static void MarkScenesDirty(List objects) { HashSet scenes = new HashSet(); foreach (WorldObject obj in objects) scenes.Add(obj.gameObject.scene); foreach (Scene scene in scenes) if (scene.IsValid()) EditorSceneManager.MarkSceneDirty(scene); } /// /// A short, readable summary of the objects that were given an id. /// private static string Describe(List objects) { List names = new List(); for (int i = 0; i < objects.Count && i < MaxNamesInSummary; i++) names.Add($"{objects[i].name} (id {objects[i].Id})"); if (objects.Count > MaxNamesInSummary) names.Add($"… and {objects.Count - MaxNamesInSummary} more"); return string.Join(", ", names); } /// /// Hierarchy path of an object, used both for the deterministic ordering and for error messages /// that must point at one specific object among many identically named ones. /// private static string GetPath(WorldObject obj) { string path = obj.name; Transform current = obj.transform.parent; while (current != null) { path = $"{current.name}/{path}"; current = current.parent; } return path; } #endregion } }