Files
Emberwild/Assets/GAME/Script/Editor/WorldObjectIdAssigner.cs
T
Mathew 78bfdf2828 Merge remote-tracking branch 'origin/feat/inport-props' into feat/craft-discovery
# Conflicts:
#	Assets/External/Animated PBR Chest Demo/Materials/WoodChest.mat
#	Packages/com.distantlands.cozy.core/Content/Integration/Import for BiRP.unitypackage.meta
#	Packages/com.distantlands.cozy.core/Content/Integration/Import for HDRP.unitypackage.meta
#	Packages/com.distantlands.cozy.core/Content/Integration/Import for URP.unitypackage.meta
2026-07-25 19:42:26 +02:00

271 lines
11 KiB
C#

using System.Collections.Generic;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
using Ashwild.Network;
namespace Ashwild.EditorTools
{
/// <summary>
/// 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 <i>assets</i>, 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 <b>stable and additive</b>: 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
/// <c>InstanceID</c> — 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.
/// </summary>
public static class WorldObjectIdAssigner
{
#region Constants
private const string LogPrefix = "[WorldObjectIds]";
/// <summary>
/// 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.
/// </summary>
private const int MaxNamesInSummary = 8;
#endregion
#region Menu
/// <summary>
/// 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.
/// </summary>
[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<WorldObject> objects)) return;
List<WorldObject> assigned = AssignMissingIds(objects);
if (assigned.Count > 0) MarkScenesDirty(objects);
Report(objects, clearedPrefabs, assigned);
Validate(objects);
}
#endregion
#region Steps
/// <summary>
/// Strips the id baked into WorldObject prefab <i>assets</i>, 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.
/// </summary>
private static int ClearPrefabIds()
{
string[] guids = AssetDatabase.FindAssets("t:Prefab");
int cleared = 0;
foreach (string guid in guids)
{
GameObject prefab = AssetDatabase.LoadAssetAtPath<GameObject>(AssetDatabase.GUIDToAssetPath(guid));
if (prefab == null) continue;
bool changed = false;
foreach (WorldObject obj in prefab.GetComponentsInChildren<WorldObject>(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;
}
/// <summary>
/// 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.
/// </summary>
private static List<WorldObject> AssignMissingIds(List<WorldObject> objects)
{
HashSet<int> taken = new HashSet<int>();
List<WorldObject> assigned = new List<WorldObject>();
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;
}
/// <summary>
/// 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.
/// </summary>
private static void Validate(List<WorldObject> objects)
{
Dictionary<int, WorldObject> byId = new Dictionary<int, WorldObject>();
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.");
}
/// <summary>
/// Logs what the run changed, in one line, and reminds to save when the scene was touched.
/// </summary>
private static void Report(List<WorldObject> objects, int clearedPrefabs, List<WorldObject> 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
/// <summary>
/// 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.
/// </summary>
private static bool TryCollect(out List<WorldObject> objects)
{
objects = null;
WorldObject[] found = Object.FindObjectsByType<WorldObject>(FindObjectsInactive.Include, FindObjectsSortMode.None);
if (found.Length == 0)
{
Debug.Log($"{LogPrefix} No WorldObject found in the open scene(s).");
return false;
}
objects = new List<WorldObject>(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;
}
/// <summary>
/// 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.
/// </summary>
private static void WriteId(WorldObject obj, int id)
{
SerializedObject so = new SerializedObject(obj);
so.FindProperty("id").intValue = id;
so.ApplyModifiedPropertiesWithoutUndo();
EditorUtility.SetDirty(obj);
}
/// <summary>
/// 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.
/// </summary>
private static void MarkScenesDirty(List<WorldObject> objects)
{
HashSet<Scene> scenes = new HashSet<Scene>();
foreach (WorldObject obj in objects)
scenes.Add(obj.gameObject.scene);
foreach (Scene scene in scenes)
if (scene.IsValid()) EditorSceneManager.MarkSceneDirty(scene);
}
/// <summary>
/// A short, readable summary of the objects that were given an id.
/// </summary>
private static string Describe(List<WorldObject> objects)
{
List<string> names = new List<string>();
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);
}
/// <summary>
/// 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.
/// </summary>
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
}
}