647 lines
34 KiB
C#
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
|
|
}
|
|
}
|