Files
Emberwild/Assets/GAME/Script/Editor/Database/BuildableAssetFactory.cs
T
2026-07-22 12:56:13 +02:00

647 lines
34 KiB
C#

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
{
/// <summary>
/// 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 <see cref="FloorEdges"/> | <see cref="WallMounts"/>.
///
/// 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 <see cref="WallMounts"/> (faces up, on a
/// floor) with <see cref="WallBody"/>'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.
/// </summary>
[System.Flags]
public enum BuildSnapLayout
{
None = 0,
/// <summary>Four horizontal edge sockets (category Floor) so slabs chain edge to edge.</summary>
FloorEdges = 1 << 0,
/// <summary>Four upward-facing sockets on the top edges (category Wall) where a wall plants its foot.</summary>
WallMounts = 1 << 1,
/// <summary>This piece IS a wall: a downward foot plus two end sockets (category Wall) for wall-to-wall runs.</summary>
WallBody = 1 << 2,
/// <summary>A single upward socket at the centre of the top face (category Roof) where a roof lands.</summary>
RoofMount = 1 << 3,
/// <summary>This piece IS a roof/ceiling: a single downward socket at its centre, mating a RoofMount.</summary>
RoofBody = 1 << 4,
/// <summary>Up/down caps (category Pillar) so pillars stack vertically.</summary>
PillarCaps = 1 << 5
}
/// <summary>
/// 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.
/// </summary>
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
/// <summary>
/// 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.
/// </summary>
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<BuildableData>();
AssetDatabase.CreateAsset(buildable, assetPath);
SerializedObject so = new SerializedObject(buildable);
so.FindProperty("displayName").stringValue = safeName;
so.ApplyModifiedPropertiesWithoutUndo();
EditorUtility.SetDirty(buildable);
AssetDatabase.SaveAssets();
return buildable;
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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<NetworkObject>();
root.AddComponent<BuiltStructure>();
root.AddComponent<ClearGrassOnPlace>();
AddSnapSockets(root, bounds, layout, true);
return SaveAndAssign(root, $"{BuiltPrefabFolder}/{buildable.name}.prefab", buildable, "builtPrefab");
}
/// <summary>
/// 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.
/// </summary>
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<NetworkObject>();
NetworkTransform netTransform = root.AddComponent<NetworkTransform>();
SerializedObject transformSo = new SerializedObject(netTransform);
transformSo.FindProperty("_clientAuthoritative").boolValue = true;
transformSo.ApplyModifiedPropertiesWithoutUndo();
BuildGhost ghost = root.AddComponent<BuildGhost>();
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");
}
/// <summary>
/// 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.
/// </summary>
private static void AssignRenderers(SerializedProperty arrayProperty, GameObject root)
{
Renderer[] renderers = root.GetComponentsInChildren<Renderer>(true);
arrayProperty.arraySize = renderers.Length;
for (int i = 0; i < renderers.Length; i++)
arrayProperty.GetArrayElementAtIndex(i).objectReferenceValue = renderers[i];
}
#endregion
#region Snap Sockets
/// <summary>
/// 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 <see cref="BuildSnapLayout.None"/>, leaving a piece that can only
/// be free-placed.
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
private static void AddRoofMount(GameObject container, Vector3 half, bool trigger)
{
CreateSocket(container, "RoofMount", new Vector3(0f, half.y, 0f), Vector3.up, SnapCategory.Roof, trigger);
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// Creates one socket: a child on the BuildSnap layer oriented so its forward is
/// <paramref name="outward"/>, 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.
/// </summary>
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<BuildSnapPoint>();
SerializedObject so = new SerializedObject(point);
so.FindProperty("category").enumValueIndex = (int)category;
so.ApplyModifiedPropertiesWithoutUndo();
SphereCollider collider = socket.AddComponent<SphereCollider>();
collider.radius = SnapColliderRadius;
collider.isTrigger = trigger;
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
public static BuildSnapLayout InferLayout(GameObject source)
{
return source == null ? BuildSnapLayout.None : InferLayout(MeasureSource(source).size);
}
/// <summary>
/// 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.
/// </summary>
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
/// <summary>
/// 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.
/// </summary>
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<Renderer>();
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;
}
/// <summary>
/// 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 <paramref name="layer"/>. 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.
/// </summary>
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;
}
/// <summary>
/// Puts a GameObject and every descendant on one layer.
/// </summary>
private static void SetLayerRecursively(GameObject go, int layer)
{
go.layer = layer;
foreach (Transform child in go.transform)
SetLayerRecursively(child.gameObject, layer);
}
/// <summary>
/// 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.
/// </summary>
private static void DisableColliders(GameObject root)
{
foreach (Collider collider in root.GetComponentsInChildren<Collider>(true))
collider.enabled = false;
}
/// <summary>
/// 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.
/// </summary>
private static int ResolveGhostLayer()
{
int layer = LayerMask.NameToLayer(GhostLayerName);
return layer >= 0 ? layer : ResolveLayer(BuildLayerName);
}
/// <summary>
/// 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.
/// </summary>
private static BoxCollider FitBoxCollider(GameObject root, Bounds bounds, bool enabled)
{
BoxCollider box = root.AddComponent<BoxCollider>();
box.center = bounds.center;
box.size = bounds.size;
box.enabled = enabled;
return box;
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
private static Material LoadOverlay(string path)
{
Material material = AssetDatabase.LoadAssetAtPath<Material>(path);
if (material == null)
Debug.LogWarning($"[BuildableAssetFactory] Ghost overlay material not found at '{path}' — assign it by hand on the generated ghost.");
return material;
}
/// <summary>
/// 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.
/// </summary>
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.");
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// Ensures a project-relative asset folder exists, creating any missing segments. Returns
/// false (and logs) when the path cannot be created.
/// </summary>
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
}
}