using System.IO; using System.Text; using UnityEditor; using UnityEngine; using Ashwild.Building; using Ashwild.GrassClearer; using FishNet.Component.Transforming; using FishNet.Object; namespace Ashwild.EditorTools { /// /// Which socket sets to generate on a buildable. Combinable, because one piece usually offers several /// kinds of connection at once: a floor slab chains to other slabs by its edges AND hosts walls on /// those same edges, so it is authored as | . /// /// Each flag describes a connection the piece OFFERS, not what the piece is called, so the sets /// compose without overlapping. The pairs that mate are (faces up, on a /// floor) with 's foot (faces down, on the wall) — opposing forwards of the /// same category, which is exactly what BuildManager requires to connect two sockets. /// /// Lives here rather than in the runtime assembly because it is purely an authoring-time hint: the /// generated prefabs only ever carry plain BuildSnapPoints. /// [System.Flags] public enum BuildSnapLayout { None = 0, /// Four horizontal edge sockets (category Floor) so slabs chain edge to edge. FloorEdges = 1 << 0, /// Four upward-facing sockets on the top edges (category Wall) where a wall plants its foot. WallMounts = 1 << 1, /// This piece IS a wall: a downward foot plus two end sockets (category Wall) for wall-to-wall runs. WallBody = 1 << 2, /// A single upward socket at the centre of the top face (category Roof) where a roof lands. RoofMount = 1 << 3, /// This piece IS a roof/ceiling: a single downward socket at its centre, mating a RoofMount. RoofBody = 1 << 4, /// Up/down caps (category Pillar) so pillars stack vertically. PillarCaps = 1 << 5 } /// /// Editor-only factory that creates BuildableData assets and, on demand, generates the two prefabs a /// buildable needs from a single source mesh or prefab — mirroring ItemAssetFactory for items. /// /// The generated pair follows the authored convention exactly (see Ghost_WoodCelling / WoodCelling_Prefab): /// - Built prefab — root on the Build layer carrying the source as a child, a non-trigger BoxCollider /// fitted to the renderers, BuiltStructure (bump / highlight / damage entry) and ClearGrassOnPlace. /// - Ghost prefab — the same root plus a NetworkObject and a client-authoritative NetworkTransform /// (BuildRegistry spawns the ghost as a real NetworkObject, §3's deliberate low-count exception), /// and a BuildGhost wired to the footprint collider and the two overlay materials. /// /// Both get a "Snap" container of BuildSnapPoints on the BuildSnap layer, positioned from the source's /// bounds with each socket's forward pointing OUT of the piece — the orientation BuildManager requires, /// since two sockets only connect when their forwards oppose. The built copy's sockets are triggers so /// the placement overlap finds them; the ghost's are not, so a ghost never snaps to itself. /// /// The layout is a best guess from the mesh proportions and is meant to be adjusted by hand afterwards; /// generation exists to kill the repetitive part, not to replace authoring judgement. /// public static class BuildableAssetFactory { #region Constants private const string BuildablesFolder = "Assets/GAME/ScriptableObjects/Buildables"; private const string BuiltPrefabFolder = "Assets/GAME/Prefabs/Structure/Build/Build"; private const string GhostPrefabFolder = "Assets/GAME/Prefabs/Structure/Build/Ghost"; private const string ValidOverlayPath = "Assets/GAME/Shaders/Build/GAME_BuildGhost_Good.mat"; private const string InvalidOverlayPath = "Assets/GAME/Shaders/Build/GAME_BuildGhost_Not.mat"; private const string BuildLayerName = "Build"; private const string SnapLayerName = "BuildSnap"; private const string GhostLayerName = "BuildGhost"; private const string SnapContainerName = "Snap"; private const float SnapColliderRadius = 0.5f; private const string RefreshPrefabsMenu = "Tools/Fish-Networking/Utility/Refresh Default Prefabs"; private static readonly Vector3 FallbackSize = new Vector3(1f, 1f, 1f); #endregion #region Buildable Asset /// /// Creates a fresh BuildableData asset under the buildables folder with a unique name, seeding /// its display name so the new asset is immediately coherent in the menu list. Returns the asset /// for selection, or null if the folder cannot be made. /// public static BuildableData CreateBuildable(string desiredName) { if (!EnsureFolder(BuildablesFolder)) return null; string safeName = string.IsNullOrWhiteSpace(desiredName) ? "NewBuildable" : desiredName.Trim(); string assetPath = AssetDatabase.GenerateUniqueAssetPath($"{BuildablesFolder}/{safeName}.asset"); BuildableData buildable = ScriptableObject.CreateInstance(); AssetDatabase.CreateAsset(buildable, assetPath); SerializedObject so = new SerializedObject(buildable); so.FindProperty("displayName").stringValue = safeName; so.ApplyModifiedPropertiesWithoutUndo(); EditorUtility.SetDirty(buildable); AssetDatabase.SaveAssets(); return buildable; } /// /// Renames the asset file so it matches the authored display name — a buildable called "Wood Wall" /// should not sit on disk as "NewBuildable 2". Invalid path characters are stripped and the result /// is uniquified, so a clash with an existing file never overwrites it. No-op when the name is /// blank or already matches. Returns true when the file was actually renamed. /// public static bool RenameAssetToDisplayName(BuildableData buildable) { if (buildable == null) return false; string desired = SanitiseFileName(buildable.DisplayName); if (string.IsNullOrEmpty(desired) || desired == buildable.name) return false; string path = AssetDatabase.GetAssetPath(buildable); if (string.IsNullOrEmpty(path)) return false; string folder = Path.GetDirectoryName(path).Replace('\\', '/'); string unique = Path.GetFileNameWithoutExtension(AssetDatabase.GenerateUniqueAssetPath($"{folder}/{desired}.asset")); string error = AssetDatabase.RenameAsset(path, unique); if (!string.IsNullOrEmpty(error)) { Debug.LogWarning($"[BuildableAssetFactory] Could not rename '{buildable.name}' to '{unique}' — {error}.", buildable); return false; } AssetDatabase.SaveAssets(); return true; } /// /// Strips characters the file system rejects (and collapses surrounding whitespace) so a display /// name typed freely by a designer can safely become an asset file name. /// private static string SanitiseFileName(string raw) { if (string.IsNullOrWhiteSpace(raw)) return string.Empty; char[] invalid = Path.GetInvalidFileNameChars(); StringBuilder builder = new StringBuilder(raw.Length); foreach (char c in raw.Trim()) if (System.Array.IndexOf(invalid, c) < 0) builder.Append(c); return builder.ToString().Trim(); } #endregion #region Prefab Generation /// /// Generates the missing prefabs for a buildable from one source object (an FBX model or an /// existing prefab), wires them onto the asset and returns true when at least one was created. /// Only the slots asked for are built, so a designer can regenerate just the ghost after tweaking /// the model without losing hand-edits on the built prefab. /// /// The source is nested as a child rather than flattened, so it stays a live prefab link the /// designer can keep editing. Because the ghost is a NetworkObject it must live in /// DefaultPrefabObjects; FishNet's generator picks it up on import, and we force a refresh /// afterwards so the registration is deterministic rather than dependent on import timing. /// public static bool GeneratePrefabs(BuildableData buildable, GameObject source, BuildSnapLayout layout, bool generateBuilt, bool generateGhost) { if (buildable == null) { Debug.LogError("[BuildableAssetFactory] Cannot generate prefabs — no BuildableData selected."); return false; } if (source == null) { Debug.LogError($"[BuildableAssetFactory] Cannot generate prefabs for '{buildable.name}' — no source mesh or prefab chosen.", buildable); return false; } if (!generateBuilt && !generateGhost) return false; if (!EnsureFolder(BuiltPrefabFolder) || !EnsureFolder(GhostPrefabFolder)) return false; Bounds bounds = MeasureSource(source); bool created = false; if (generateBuilt) created |= BuildBuiltPrefab(buildable, source, bounds, layout) != null; if (generateGhost) created |= BuildGhostPrefab(buildable, source, bounds, layout) != null; if (created) { ApplySupport(buildable, layout); AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); RefreshNetworkPrefabRegistry(); } return created; } /// /// Builds and saves the committed-structure prefab: the source on the Build layer under a root /// carrying a fitted non-trigger BoxCollider (what the demolition ray and obstruction tests hit), /// BuiltStructure and ClearGrassOnPlace, plus trigger snap sockets the placement overlap can find. /// /// Committed structures are spawned by the server as real NetworkObjects, so the root gets one — /// without it BuiltStructure (a NetworkBehaviour) cannot function and the prefab is not spawnable. /// private static GameObject BuildBuiltPrefab(BuildableData buildable, GameObject source, Bounds bounds, BuildSnapLayout layout) { GameObject root = BuildRoot(buildable.name, source, ResolveLayer(BuildLayerName)); FitBoxCollider(root, bounds, true); root.AddComponent(); root.AddComponent(); root.AddComponent(); AddSnapSockets(root, bounds, layout, true); return SaveAndAssign(root, $"{BuiltPrefabFolder}/{buildable.name}.prefab", buildable, "builtPrefab"); } /// /// Builds and saves the placement-preview prefab: the same root plus the networking pair the /// registry needs (NetworkObject + client-authoritative NetworkTransform, since the placer owns /// and drives its own ghost) and a BuildGhost wired to the footprint collider and the shared /// valid/invalid overlay materials. Its snap colliders are left as non-triggers, matching the /// authored ghost, so the ghost never registers as a snap target for itself. /// /// The child renderers are assigned explicitly rather than left to BuildGhost's Awake fallback, so /// the overlay targets are visible (and editable) in the prefab exactly like the authored ghost. /// private static GameObject BuildGhostPrefab(BuildableData buildable, GameObject source, Bounds bounds, BuildSnapLayout layout) { GameObject root = BuildRoot($"Ghost_{buildable.name}", source, ResolveGhostLayer()); BoxCollider footprint = FitBoxCollider(root, bounds, false); root.AddComponent(); NetworkTransform netTransform = root.AddComponent(); SerializedObject transformSo = new SerializedObject(netTransform); transformSo.FindProperty("_clientAuthoritative").boolValue = true; transformSo.ApplyModifiedPropertiesWithoutUndo(); BuildGhost ghost = root.AddComponent(); SerializedObject ghostSo = new SerializedObject(ghost); ghostSo.FindProperty("footprint").objectReferenceValue = footprint; ghostSo.FindProperty("validOverlay").objectReferenceValue = LoadOverlay(ValidOverlayPath); ghostSo.FindProperty("invalidOverlay").objectReferenceValue = LoadOverlay(InvalidOverlayPath); AssignRenderers(ghostSo.FindProperty("renderers"), root); ghostSo.ApplyModifiedPropertiesWithoutUndo(); AddSnapSockets(root, bounds, layout, false); DisableColliders(root); return SaveAndAssign(root, $"{GhostPrefabFolder}/Ghost_{buildable.name}.prefab", buildable, "ghostPrefab"); } /// /// Fills a serialized Renderer array with every renderer found under the root, including inactive /// ones — the ghost swaps materials on all of them, so a renderer left out would keep its opaque /// look while the rest of the piece turns translucent. /// private static void AssignRenderers(SerializedProperty arrayProperty, GameObject root) { Renderer[] renderers = root.GetComponentsInChildren(true); arrayProperty.arraySize = renderers.Length; for (int i = 0; i < renderers.Length; i++) arrayProperty.GetArrayElementAtIndex(i).objectReferenceValue = renderers[i]; } #endregion #region Snap Sockets /// /// Creates the "Snap" container at the source's bounds centre and fills it with every socket set /// the layout asks for — they compose, so a floor slab gets its edge ring AND its wall mounts from /// one pass. Nothing is added for , leaving a piece that can only /// be free-placed. /// private static void AddSnapSockets(GameObject root, Bounds bounds, BuildSnapLayout layout, bool trigger) { if (layout == BuildSnapLayout.None) return; GameObject container = new GameObject(SnapContainerName) { layer = root.layer }; container.transform.SetParent(root.transform, false); container.transform.localPosition = bounds.center; Vector3 half = bounds.extents; if (layout.HasFlag(BuildSnapLayout.FloorEdges)) AddFloorEdges(container, half, trigger); if (layout.HasFlag(BuildSnapLayout.RoofMount)) AddRoofMount(container, half, trigger); if (layout.HasFlag(BuildSnapLayout.RoofBody)) AddRoofBody(container, half, trigger); if (layout.HasFlag(BuildSnapLayout.WallMounts)) AddWallMounts(container, half, trigger); if (layout.HasFlag(BuildSnapLayout.WallBody)) AddWallBody(container, half, trigger); if (layout.HasFlag(BuildSnapLayout.PillarCaps)) AddPillarCaps(container, half, trigger); } /// /// Adds the four horizontal edge sockets of a slab, at the midpoint of each side and at /// mid-thickness, each facing straight out of the piece so a neighbour clicks in edge-to-edge. /// private static void AddFloorEdges(GameObject container, Vector3 half, bool trigger) { CreateSocket(container, "Floor_North", new Vector3(0f, 0f, half.z), Vector3.forward, SnapCategory.Floor, trigger); CreateSocket(container, "Floor_South", new Vector3(0f, 0f, -half.z), Vector3.back, SnapCategory.Floor, trigger); CreateSocket(container, "Floor_East", new Vector3(half.x, 0f, 0f), Vector3.right, SnapCategory.Floor, trigger); CreateSocket(container, "Floor_West", new Vector3(-half.x, 0f, 0f), Vector3.left, SnapCategory.Floor, trigger); } /// /// Adds the single socket a roof lands on: centred on the top face and facing up, because a roof /// or ceiling sits centred over the piece rather than hooking onto its sides. Deliberately ONE /// socket, not a ring — four of them would let a roof snap offset to an edge, and two adjacent /// pieces would each offer a competing target for the same roof. /// private static void AddRoofMount(GameObject container, Vector3 half, bool trigger) { CreateSocket(container, "RoofMount", new Vector3(0f, half.y, 0f), Vector3.up, SnapCategory.Roof, trigger); } /// /// Adds the counterpart a roof or ceiling piece needs: one socket under its centre facing DOWN, /// which mates the upward RoofMount offered by whatever it rests on. Without this set a RoofMount /// has nothing to pair with — two sockets connect only when their forwards oppose. /// private static void AddRoofBody(GameObject container, Vector3 half, bool trigger) { CreateSocket(container, "Roof_Base", new Vector3(0f, -half.y, 0f), Vector3.down, SnapCategory.Roof, trigger); } /// /// Adds the sockets a floor offers to walls: one per top edge, facing UP. A wall's foot faces down, /// so the two oppose and connect — which also means a wall mount can never be mistaken for a floor /// edge socket (those face sideways), even though a slab carries both rings at once. /// private static void AddWallMounts(GameObject container, Vector3 half, bool trigger) { CreateSocket(container, "WallMount_North", new Vector3(0f, half.y, half.z), Vector3.up, SnapCategory.Wall, trigger); CreateSocket(container, "WallMount_South", new Vector3(0f, half.y, -half.z), Vector3.up, SnapCategory.Wall, trigger); CreateSocket(container, "WallMount_East", new Vector3(half.x, half.y, 0f), Vector3.up, SnapCategory.Wall, trigger); CreateSocket(container, "WallMount_West", new Vector3(-half.x, half.y, 0f), Vector3.up, SnapCategory.Wall, trigger); } /// /// Adds a standing panel's own sockets: a foot at the base facing DOWN (which mates a floor's /// upward wall mount) plus one socket on each end of its long axis facing outward, so walls chain /// into runs. The long axis is taken as whichever horizontal dimension is larger. /// private static void AddWallBody(GameObject container, Vector3 half, bool trigger) { bool thinAlongZ = half.z <= half.x; Vector3 longAxis = thinAlongZ ? Vector3.right : Vector3.forward; float longHalf = thinAlongZ ? half.x : half.z; CreateSocket(container, "Wall_Foot", new Vector3(0f, -half.y, 0f), Vector3.down, SnapCategory.Wall, trigger); CreateSocket(container, "Wall_SideA", longAxis * longHalf, longAxis, SnapCategory.Wall, trigger); CreateSocket(container, "Wall_SideB", -longAxis * longHalf, -longAxis, SnapCategory.Wall, trigger); } /// /// Adds a pillar's two caps so pillars stack vertically: the top faces up and the bottom faces /// down, which is exactly the opposing pair the snapping requires. /// private static void AddPillarCaps(GameObject container, Vector3 half, bool trigger) { CreateSocket(container, "Cap_Top", new Vector3(0f, half.y, 0f), Vector3.up, SnapCategory.Pillar, trigger); CreateSocket(container, "Cap_Bottom", new Vector3(0f, -half.y, 0f), Vector3.down, SnapCategory.Pillar, trigger); } /// /// Creates one socket: a child on the BuildSnap layer oriented so its forward is /// , carrying a BuildSnapPoint of the given category and the sphere /// collider the placement overlap detects it by. Sockets are triggers on the built piece (the /// snap targets the placement scan looks for) and plain colliders on the ghost, matching the /// authored prefabs — the ghost is excluded from its own scan by parentage, not by collider type. /// private static void CreateSocket(GameObject container, string name, Vector3 localPosition, Vector3 outward, SnapCategory category, bool trigger) { GameObject socket = new GameObject(name) { layer = ResolveLayer(SnapLayerName) }; socket.transform.SetParent(container.transform, false); socket.transform.localPosition = localPosition; socket.transform.localRotation = SocketRotation(outward); BuildSnapPoint point = socket.AddComponent(); SerializedObject so = new SerializedObject(point); so.FindProperty("category").enumValueIndex = (int)category; so.ApplyModifiedPropertiesWithoutUndo(); SphereCollider collider = socket.AddComponent(); collider.radius = SnapColliderRadius; collider.isTrigger = trigger; } /// /// Turns an outward direction into a socket rotation. A vertical direction needs a non-parallel /// reference up, otherwise LookRotation degenerates and the cap sockets come out unrotated. /// private static Quaternion SocketRotation(Vector3 outward) { Vector3 up = Mathf.Abs(Vector3.Dot(outward, Vector3.up)) > 0.99f ? Vector3.forward : Vector3.up; return Quaternion.LookRotation(outward, up); } /// /// Derives the buildable's placement rule from the sockets it was just given: a piece authored as /// a wall body or a roof body only makes sense attached to something, so it becomes SnapOnly and /// can no longer be dropped in mid-air. Everything else — foundations, floor slabs — stays free to /// place on the ground. Doing it here means the designer never has to remember to set the two /// fields consistently: the socket layout already says what kind of piece this is. /// private static void ApplySupport(BuildableData buildable, BuildSnapLayout layout) { bool needsConnection = layout.HasFlag(BuildSnapLayout.WallBody) || layout.HasFlag(BuildSnapLayout.RoofBody); SerializedObject so = new SerializedObject(buildable); so.FindProperty("support").enumValueIndex = (int)(needsConnection ? PlacementSupport.SnapOnly : PlacementSupport.GroundOrSnap); so.ApplyModifiedPropertiesWithoutUndo(); EditorUtility.SetDirty(buildable); } /// /// Suggests a layout for a source model, so picking a mesh in the window pre-ticks a sensible set /// the designer can then adjust. Measures the mesh, so it is only worth calling when the source /// actually changes. /// public static BuildSnapLayout InferLayout(GameObject source) { return source == null ? BuildSnapLayout.None : InferLayout(MeasureSource(source).size); } /// /// Picks a socket layout from the mesh's proportions: a square-ish tall block stacks as a pillar, /// a tall panel thin on one horizontal axis is a wall body, and anything flat enough to stand on /// is a floor — which gets BOTH its edge ring (slab to slab) and its wall mounts, since a floor /// piece almost always has to host walls too. That combination is the reason the layout is a set /// of flags rather than a single choice. /// private static BuildSnapLayout InferLayout(Vector3 size) { const BuildSnapLayout slab = BuildSnapLayout.FloorEdges | BuildSnapLayout.WallMounts; float minHorizontal = Mathf.Min(size.x, size.z); float maxHorizontal = Mathf.Max(size.x, size.z); if (maxHorizontal <= Mathf.Epsilon) return slab; if (size.y <= 0.5f * minHorizontal) return slab; if (size.y >= 1.5f * maxHorizontal && maxHorizontal <= 1.6f * minHorizontal) return BuildSnapLayout.PillarCaps; if (minHorizontal <= 0.35f * maxHorizontal && size.y >= 0.6f * maxHorizontal) return BuildSnapLayout.WallBody; return slab; } #endregion #region Internal Helpers /// /// Measures the source's combined renderer bounds in its own local space by instantiating it once /// at the origin, so socket positions and the footprint are derived from the real mesh rather than /// guessed. Falls back to a unit box when the source has no renderers, so generation still yields a /// usable (if arbitrary) footprint instead of a degenerate zero-size collider. /// private static Bounds MeasureSource(GameObject source) { GameObject probe = (GameObject)PrefabUtility.InstantiatePrefab(source); if (probe == null) probe = Object.Instantiate(source); probe.transform.position = Vector3.zero; probe.transform.rotation = Quaternion.identity; Renderer[] renderers = probe.GetComponentsInChildren(); Bounds bounds = renderers.Length > 0 ? renderers[0].bounds : new Bounds(Vector3.zero, FallbackSize); for (int i = 1; i < renderers.Length; i++) bounds.Encapsulate(renderers[i].bounds); Object.DestroyImmediate(probe); return bounds; } /// /// Creates the generated root with the source nested as a child at the origin, so the designer's /// authored model stays a prefab link rather than being flattened into the copy. /// /// The whole nested subtree is forced onto . Source models routinely ship /// on their own layer (a wall model authored on Build, say), and leaving that alone would make the /// generated piece behave inconsistently — worst of all on a ghost, whose visual must not sit on a /// layer the obstruction test scans. /// private static GameObject BuildRoot(string name, GameObject source, int layer) { GameObject root = new GameObject(name) { layer = layer }; GameObject visual = (GameObject)PrefabUtility.InstantiatePrefab(source); if (visual == null) visual = Object.Instantiate(source); visual.transform.SetParent(root.transform, false); visual.transform.localPosition = Vector3.zero; SetLayerRecursively(visual, layer); return root; } /// /// Puts a GameObject and every descendant on one layer. /// private static void SetLayerRecursively(GameObject go, int layer) { go.layer = layer; foreach (Transform child in go.transform) SetLayerRecursively(child.gameObject, layer); } /// /// Disables every collider under the ghost, including any the source model brought with it. /// /// This is what keeps a ghost from reporting itself as blocked: BuildGhost box-overlaps its /// footprint against the obstruction mask, and an enabled collider anywhere in the preview — the /// source model's own BoxCollider is the usual culprit, since it is authored on the Build layer — /// lands inside that very box, so the spot reads as occupied everywhere and the ghost never turns /// green. A ghost needs no physics at all: its footprint is read as raw dimensions, and its snap /// sockets are found through GetComponentsInChildren, never by an overlap query. /// private static void DisableColliders(GameObject root) { foreach (Collider collider in root.GetComponentsInChildren(true)) collider.enabled = false; } /// /// The layer generated ghosts go on: a dedicated "BuildGhost" layer when the project defines one /// (the clean setup — it keeps previews out of every mask aimed at real structures), otherwise the /// Build layer, matching the original authored ghosts. Silent by design, since the dedicated layer /// is optional. /// private static int ResolveGhostLayer() { int layer = LayerMask.NameToLayer(GhostLayerName); return layer >= 0 ? layer : ResolveLayer(BuildLayerName); } /// /// Adds the root's BoxCollider sized to the measured bounds. It is left enabled on the built piece /// (its physical body, and what the demolition ray hits) but DISABLED on the ghost: the obstruction /// mask is the Build layer the ghost itself sits on, so an enabled footprint would overlap itself /// and report every spot as blocked. BuildGhost only ever reads the collider's centre/size, never /// its physics contacts, so a disabled collider still serves as the bounds source. /// private static BoxCollider FitBoxCollider(GameObject root, Bounds bounds, bool enabled) { BoxCollider box = root.AddComponent(); box.center = bounds.center; box.size = bounds.size; box.enabled = enabled; return box; } /// /// Saves a built GameObject as a prefab under a unique path, destroys the scene instance, wires the /// saved prefab onto the given BuildableData field and marks the asset dirty. /// private static GameObject SaveAndAssign(GameObject root, string desiredPath, BuildableData buildable, string field) { string prefabPath = AssetDatabase.GenerateUniqueAssetPath(desiredPath); GameObject prefab = PrefabUtility.SaveAsPrefabAsset(root, prefabPath); Object.DestroyImmediate(root); if (prefab == null) { Debug.LogError($"[BuildableAssetFactory] Failed to save prefab at '{prefabPath}'.", buildable); return null; } SerializedObject so = new SerializedObject(buildable); so.FindProperty(field).objectReferenceValue = prefab; so.ApplyModifiedPropertiesWithoutUndo(); EditorUtility.SetDirty(buildable); Debug.Log($"[BuildableAssetFactory] Built '{prefabPath}' and assigned it to {buildable.name}.{field}.", prefab); return prefab; } /// /// Loads one of the shared ghost overlay materials, warning (rather than failing generation) when /// it has been moved — a ghost without overlays still works, it just renders opaque. /// private static Material LoadOverlay(string path) { Material material = AssetDatabase.LoadAssetAtPath(path); if (material == null) Debug.LogWarning($"[BuildableAssetFactory] Ghost overlay material not found at '{path}' — assign it by hand on the generated ghost."); return material; } /// /// Forces FishNet to rescan DefaultPrefabObjects so the freshly saved ghost is spawnable straight /// away. Invoked through the menu item rather than the generator API, which is internal to the /// FishNet assembly and so unreachable from this one. /// private static void RefreshNetworkPrefabRegistry() { if (!EditorApplication.ExecuteMenuItem(RefreshPrefabsMenu)) Debug.LogWarning($"[BuildableAssetFactory] Could not run '{RefreshPrefabsMenu}' — run it by hand so the ghost is registered as a spawnable network prefab."); } /// /// Resolves a layer index by name, falling back to the Default layer (0) with a warning when the /// project is missing the expected layer so generation never silently lands on a wrong one. /// private static int ResolveLayer(string layerName) { int layer = LayerMask.NameToLayer(layerName); if (layer >= 0) return layer; Debug.LogWarning($"[BuildableAssetFactory] Layer '{layerName}' not found — using Default. Add it in the Tags & Layers settings."); return 0; } /// /// Ensures a project-relative asset folder exists, creating any missing segments. Returns /// false (and logs) when the path cannot be created. /// private static bool EnsureFolder(string folder) { if (AssetDatabase.IsValidFolder(folder)) return true; string parent = Path.GetDirectoryName(folder).Replace('\\', '/'); string leaf = Path.GetFileName(folder); if (!EnsureFolder(parent)) { Debug.LogError($"[BuildableAssetFactory] Could not create folder '{folder}'."); return false; } AssetDatabase.CreateFolder(parent, leaf); return AssetDatabase.IsValidFolder(folder); } #endregion } }