(Feat) Add Build
This commit is contained in:
@@ -438,3 +438,32 @@
|
||||
.ash-recipe-name {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* ── Prefab generator (shown while a prefab slot is empty) ─ */
|
||||
.ash-generator {
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
background-color: rgba(64, 110, 220, 0.07);
|
||||
border-width: 1px;
|
||||
border-color: rgb(74, 90, 130);
|
||||
}
|
||||
|
||||
.ash-generator__title {
|
||||
-unity-font-style: bold;
|
||||
font-size: 11px;
|
||||
color: rgb(150, 175, 240);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.ash-generator .ash-btn {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.ash-generator__hint {
|
||||
margin-top: 8px;
|
||||
font-size: 10px;
|
||||
color: rgb(130, 132, 140);
|
||||
-unity-font-style: italic;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
@@ -311,7 +311,7 @@ namespace Ashwild.EditorTools
|
||||
else if (selectedAsset is CraftingRecipe recipe)
|
||||
rightPane.Add(new RecipeEditorView(recipe, RefreshRow).Root);
|
||||
else if (selectedAsset is BuildableData buildable)
|
||||
rightPane.Add(new BuildableEditorView(buildable, RefreshRow).Root);
|
||||
rightPane.Add(new BuildableEditorView(buildable, RefreshRow, ShowSelection).Root);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,20 +1,91 @@
|
||||
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>
|
||||
/// Editor-only factory that creates BuildableData assets for the Ashwild Database window.
|
||||
/// Kept separate from the window so buildable authoring stays a single-purpose, testable helper —
|
||||
/// mirrors RecipeAssetFactory for recipes and ItemAssetFactory for items.
|
||||
/// 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
|
||||
|
||||
@@ -44,10 +115,512 @@ namespace Ashwild.EditorTools
|
||||
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.
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEditor;
|
||||
using UnityEditor.UIElements;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
using Ashwild.Building;
|
||||
using Ashwild.Inventory;
|
||||
|
||||
namespace Ashwild.EditorTools
|
||||
{
|
||||
/// <summary>
|
||||
/// The custom editor for a BuildableData shown in the right pane of the Ashwild Database. Replaces
|
||||
/// the default ScriptableObject inspector with a hero header (large clickable icon, editable name,
|
||||
/// "Buildable" badge) and cards for the identity (description) and the ghost/built prefabs the
|
||||
/// construction menu and placement ghost consume. All fields bind live to the asset; changing the
|
||||
/// name or icon notifies the list so the row updates without a full rebuild.
|
||||
/// "Buildable" badge) and cards for the identity (description), the ghost/built prefabs, the resource
|
||||
/// cost, and the placed structure's health. The cost card reuses the recipe editor's interactive chip
|
||||
/// strip — one clickable item card (with a −/+ quantity stepper and a remove button) per cost line,
|
||||
/// joined by "+", followed by a dashed add tile — so authoring a price feels identical to authoring a
|
||||
/// recipe. All fields bind live to the asset; changing the name or icon notifies the list so the row
|
||||
/// updates without a full rebuild.
|
||||
/// </summary>
|
||||
public class BuildableEditorView
|
||||
{
|
||||
@@ -21,12 +26,21 @@ namespace Ashwild.EditorTools
|
||||
public VisualElement Root { get; }
|
||||
|
||||
private const int IconPickerControlId = 0x41534842;
|
||||
private const int CostPickerControlId = 0x41534843;
|
||||
|
||||
private readonly BuildableData buildable;
|
||||
private readonly SerializedObject so;
|
||||
private readonly SerializedProperty costProp;
|
||||
private readonly Action onMetaChanged;
|
||||
private readonly Action onAssetChanged;
|
||||
|
||||
private VisualElement iconPreview;
|
||||
private VisualElement costStrip;
|
||||
private Action<ItemData> costPickHandler;
|
||||
private bool costPickCommitsOnCloseOnly;
|
||||
|
||||
private GameObject generationSource;
|
||||
private BuildSnapLayout generationLayout = BuildSnapLayout.None;
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -34,18 +48,28 @@ namespace Ashwild.EditorTools
|
||||
|
||||
/// <summary>
|
||||
/// Builds the full editor tree for a buildable. <paramref name="onMetaChanged"/> is raised when
|
||||
/// the display name or icon changes so the list can refresh that row.
|
||||
/// the display name or icon changes so the list can refresh that row;
|
||||
/// <paramref name="onAssetChanged"/> re-renders the whole view after a structural change (prefab
|
||||
/// generation, asset rename) that the live bindings alone cannot reflect.
|
||||
/// </summary>
|
||||
public BuildableEditorView(BuildableData buildable, Action onMetaChanged)
|
||||
public BuildableEditorView(BuildableData buildable, Action onMetaChanged, Action onAssetChanged)
|
||||
{
|
||||
this.buildable = buildable;
|
||||
this.onMetaChanged = onMetaChanged;
|
||||
this.onAssetChanged = onAssetChanged;
|
||||
so = new SerializedObject(buildable);
|
||||
costProp = so.FindProperty("cost");
|
||||
|
||||
Root = new VisualElement();
|
||||
Root.Add(BuildHero());
|
||||
Root.Add(BuildIdentityCard());
|
||||
Root.Add(BuildPrefabsCard());
|
||||
Root.Add(BuildCostCard());
|
||||
Root.Add(BuildPlacementCard());
|
||||
Root.Add(BuildHealthCard());
|
||||
Root.Add(BuildPickerProxy());
|
||||
|
||||
RefreshCost();
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -82,6 +106,7 @@ namespace Ashwild.EditorTools
|
||||
nameField.AddToClassList("ash-hero__name");
|
||||
nameField.BindProperty(so.FindProperty("displayName"));
|
||||
nameField.RegisterValueChangedCallback(_ => onMetaChanged?.Invoke());
|
||||
nameField.RegisterCallback<FocusOutEvent>(_ => SyncAssetName());
|
||||
info.Add(AshwildUI.EditableNameRow(nameField));
|
||||
|
||||
VisualElement typeRow = new VisualElement();
|
||||
@@ -93,6 +118,19 @@ namespace Ashwild.EditorTools
|
||||
return hero;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renames the asset file to match the display name once the designer leaves the name field, so a
|
||||
/// buildable titled "Wood Wall" stops living on disk as "NewBuildable 2". Deliberately fired on
|
||||
/// focus-out rather than on every keystroke — renaming per character would spam the AssetDatabase
|
||||
/// and leave a trail of half-typed file names. Re-renders the view so the header reflects the new
|
||||
/// asset identity.
|
||||
/// </summary>
|
||||
private void SyncAssetName()
|
||||
{
|
||||
if (BuildableAssetFactory.RenameAssetToDisplayName(buildable))
|
||||
onAssetChanged?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows the buildable's icon in the hero preview, or a neutral placeholder when none is set.
|
||||
/// </summary>
|
||||
@@ -141,7 +179,7 @@ namespace Ashwild.EditorTools
|
||||
|
||||
#endregion
|
||||
|
||||
#region Cards
|
||||
#region Identity & Prefab Cards
|
||||
|
||||
/// <summary>
|
||||
/// Identity card: the description shown on the menu card, plus the icon field (kept in sync
|
||||
@@ -170,7 +208,12 @@ namespace Ashwild.EditorTools
|
||||
|
||||
/// <summary>
|
||||
/// Prefabs card: the semi-transparent ghost spawned while positioning and the real structure
|
||||
/// spawned once placement is confirmed.
|
||||
/// spawned once placement is confirmed. When either slot is still empty the card also offers the
|
||||
/// one-click generator below the fields, so the common path (drop a mesh in, press Generate) never
|
||||
/// leaves the window. The generator's visibility is resolved when the view is built rather than
|
||||
/// bound to the two fields: BindProperty raises a change event on its initial bind, so re-rendering
|
||||
/// from those callbacks would loop the view rebuild endlessly. Generating re-renders explicitly,
|
||||
/// and a hand-assigned prefab is picked up the next time the buildable is selected.
|
||||
/// </summary>
|
||||
private VisualElement BuildPrefabsCard()
|
||||
{
|
||||
@@ -184,9 +227,367 @@ namespace Ashwild.EditorTools
|
||||
built.BindProperty(so.FindProperty("builtPrefab"));
|
||||
card.Add(built);
|
||||
|
||||
if (buildable.GhostPrefab == null || buildable.BuiltPrefab == null)
|
||||
card.Add(BuildGeneratorBlock());
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The generator shown while a prefab slot is empty: pick a source model or prefab, tick which
|
||||
/// socket sets the piece should offer, and press the button to author the missing prefab(s) —
|
||||
/// collider, gameplay components, networking and snap sockets included. Only the empty slots are
|
||||
/// generated, so regenerating a ghost never clobbers a built prefab that has already been hand-tuned.
|
||||
///
|
||||
/// The layout is a mask rather than a single choice because one piece usually offers several kinds
|
||||
/// of connection: a floor both chains to other floors and hosts walls. Choosing a source pre-ticks
|
||||
/// the set inferred from the mesh proportions, which the designer is free to change.
|
||||
/// </summary>
|
||||
private VisualElement BuildGeneratorBlock()
|
||||
{
|
||||
VisualElement block = new VisualElement();
|
||||
block.AddToClassList("ash-generator");
|
||||
|
||||
Label heading = new Label("Generate from a source model");
|
||||
heading.AddToClassList("ash-generator__title");
|
||||
block.Add(heading);
|
||||
|
||||
MaskField layoutField = null;
|
||||
|
||||
ObjectField sourceField = new ObjectField("Source Mesh / Prefab")
|
||||
{
|
||||
objectType = typeof(GameObject),
|
||||
allowSceneObjects = false,
|
||||
tooltip = "The authored model (FBX) or prefab to wrap. It is nested as a child, never flattened, so you can keep editing it."
|
||||
};
|
||||
sourceField.RegisterValueChangedCallback(evt =>
|
||||
{
|
||||
generationSource = evt.newValue as GameObject;
|
||||
generationLayout = BuildableAssetFactory.InferLayout(generationSource);
|
||||
layoutField?.SetValueWithoutNotify((int)generationLayout);
|
||||
});
|
||||
block.Add(sourceField);
|
||||
|
||||
layoutField = new MaskField(
|
||||
"Snap Layout",
|
||||
new List<string> { "Floor Edges", "Wall Mounts", "Wall Body", "Roof Mount", "Roof Body", "Pillar Caps" },
|
||||
(int)generationLayout)
|
||||
{
|
||||
tooltip = "Which sockets to place — combinable. A floor slab usually wants Floor Edges (chain to other slabs) AND Wall Mounts (walls stand on its edges); a wall piece wants Wall Body."
|
||||
};
|
||||
layoutField.RegisterValueChangedCallback(evt => generationLayout = (BuildSnapLayout)evt.newValue);
|
||||
block.Add(layoutField);
|
||||
|
||||
Button generate = new Button(GenerateMissingPrefabs) { text = DescribeGeneration() };
|
||||
generate.AddToClassList("ash-btn");
|
||||
generate.AddToClassList("ash-btn--primary");
|
||||
block.Add(generate);
|
||||
|
||||
Label hint = new Label("Sockets are placed from the mesh bounds: floor edges face outward, wall mounts face up, a wall's foot faces down, and the roof mount is a single socket centred on the top face. Treat the layout as a starting point — nudge them in the prefab afterwards.");
|
||||
hint.AddToClassList("ash-generator__hint");
|
||||
block.Add(hint);
|
||||
|
||||
return block;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Labels the generate button with exactly what it will create, so the designer can tell at a
|
||||
/// glance whether pressing it touches one slot or both.
|
||||
/// </summary>
|
||||
private string DescribeGeneration()
|
||||
{
|
||||
bool needsBuilt = buildable.BuiltPrefab == null;
|
||||
bool needsGhost = buildable.GhostPrefab == null;
|
||||
|
||||
if (needsBuilt && needsGhost) return "Generate Built + Ghost Prefabs";
|
||||
return needsBuilt ? "Generate Built Prefab" : "Generate Ghost Prefab";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the generation for whichever slots are empty and re-renders the view so the new prefabs
|
||||
/// appear in their fields and the generator collapses away.
|
||||
/// </summary>
|
||||
private void GenerateMissingPrefabs()
|
||||
{
|
||||
bool generated = BuildableAssetFactory.GeneratePrefabs(
|
||||
buildable,
|
||||
generationSource,
|
||||
generationLayout,
|
||||
buildable.BuiltPrefab == null,
|
||||
buildable.GhostPrefab == null);
|
||||
|
||||
if (generated) onAssetChanged?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Health card: the hit points the placed structure starts with, edited as a plain float field.
|
||||
/// An empty cost makes the build free; likewise this drives the proportional demolition refund.
|
||||
/// </summary>
|
||||
private VisualElement BuildHealthCard()
|
||||
{
|
||||
VisualElement card = AshwildUI.Card("Health");
|
||||
|
||||
FloatField maxHealth = new FloatField("Max Health");
|
||||
maxHealth.BindProperty(so.FindProperty("maxHealth"));
|
||||
card.Add(maxHealth);
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Placement card: what this structure may rest on. Generating the prefabs sets it from the snap
|
||||
/// layout, so this is the override for a piece the layout cannot classify — or for a buildable
|
||||
/// authored before the rule existed, which defaults to the permissive Ground Or Snap.
|
||||
/// </summary>
|
||||
private VisualElement BuildPlacementCard()
|
||||
{
|
||||
VisualElement card = AshwildUI.Card("Placement");
|
||||
|
||||
EnumField support = new EnumField("Support")
|
||||
{
|
||||
tooltip = "Ground Or Snap: free placement anywhere the aim lands, or connected. Snap Only: valid solely while connected to a matching socket — use it for walls, ceilings and roofs so they cannot be dropped in mid-air."
|
||||
};
|
||||
support.BindProperty(so.FindProperty("support"));
|
||||
card.Add(support);
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Cost Card
|
||||
|
||||
/// <summary>
|
||||
/// Cost card: an interactive chip strip mirroring the recipe editor. Each cost line is a clickable
|
||||
/// item card with a −/+ quantity stepper and a remove button, the lines are joined by "+", and a
|
||||
/// dashed add tile appends a new line. An empty strip means a free build.
|
||||
/// </summary>
|
||||
private VisualElement BuildCostCard()
|
||||
{
|
||||
VisualElement card = AshwildUI.Card("Cost");
|
||||
|
||||
costStrip = new VisualElement();
|
||||
costStrip.AddToClassList("ash-equation");
|
||||
card.Add(costStrip);
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Repaints the whole cost strip: one interactive card per cost line (joined by "+"), then the
|
||||
/// dashed add tile. Shows only the add tile when the build is free.
|
||||
/// </summary>
|
||||
private void RefreshCost()
|
||||
{
|
||||
costStrip.Clear();
|
||||
|
||||
int count = costProp.arraySize;
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
if (i > 0) costStrip.Add(Operator("+"));
|
||||
costStrip.Add(BuildCostCardChip(i));
|
||||
}
|
||||
|
||||
if (count > 0) costStrip.Add(Operator("+"));
|
||||
costStrip.Add(BuildAddTile());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the interactive card for the cost line at <paramref name="index"/>: a clickable icon
|
||||
/// (change item), the name, a −/+ quantity stepper, and a remove button.
|
||||
/// </summary>
|
||||
private VisualElement BuildCostCardChip(int index)
|
||||
{
|
||||
SerializedProperty element = costProp.GetArrayElementAtIndex(index);
|
||||
ItemData item = element.FindPropertyRelative("item").objectReferenceValue as ItemData;
|
||||
int quantity = element.FindPropertyRelative("quantity").intValue;
|
||||
|
||||
VisualElement card = MakeCard(item, () => OpenItemPicker(item, true, picked => SetCostItem(index, picked)));
|
||||
card.Add(Stepper(quantity, delta => AdjustCostQuantity(index, delta)));
|
||||
|
||||
Button remove = new Button(() => RemoveCost(index)) { text = "✕" };
|
||||
remove.AddToClassList("ash-rchip__remove");
|
||||
card.Add(remove);
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the dashed "+" tile that appends a new cost line once an item is picked.
|
||||
/// </summary>
|
||||
private VisualElement BuildAddTile()
|
||||
{
|
||||
VisualElement tile = new VisualElement();
|
||||
tile.AddToClassList("ash-rchip");
|
||||
tile.AddToClassList("ash-rchip--add");
|
||||
tile.tooltip = "Add a cost line";
|
||||
tile.RegisterCallback<ClickEvent>(_ => OpenItemPicker(null, false, AddCost));
|
||||
|
||||
Label plus = new Label("+");
|
||||
plus.AddToClassList("ash-rchip__plus");
|
||||
tile.Add(plus);
|
||||
|
||||
return tile;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the shared card body (icon + name) for an item, with the icon wired to open a picker.
|
||||
/// The icon shows the item's sprite, or a neutral placeholder when unset.
|
||||
/// </summary>
|
||||
private VisualElement MakeCard(ItemData item, Action onIconClicked)
|
||||
{
|
||||
VisualElement card = new VisualElement();
|
||||
card.AddToClassList("ash-rchip");
|
||||
|
||||
VisualElement icon = new VisualElement();
|
||||
icon.AddToClassList("ash-rchip__icon");
|
||||
icon.tooltip = "Click to choose an item";
|
||||
if (item != null && item.Icon != null) icon.style.backgroundImage = new StyleBackground(item.Icon);
|
||||
icon.RegisterCallback<ClickEvent>(_ => onIconClicked());
|
||||
card.Add(icon);
|
||||
|
||||
Label name = new Label(item != null ? item.ItemName : "Choose…");
|
||||
name.AddToClassList("ash-rchip__name");
|
||||
card.Add(name);
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a −/+ quantity stepper showing the current value; <paramref name="onDelta"/> receives
|
||||
/// -1 or +1.
|
||||
/// </summary>
|
||||
private VisualElement Stepper(int quantity, Action<int> onDelta)
|
||||
{
|
||||
VisualElement stepper = new VisualElement();
|
||||
stepper.AddToClassList("ash-rchip__stepper");
|
||||
|
||||
Button minus = new Button(() => onDelta(-1)) { text = "−" };
|
||||
minus.AddToClassList("ash-rchip__step");
|
||||
stepper.Add(minus);
|
||||
|
||||
Label value = new Label(quantity.ToString());
|
||||
value.AddToClassList("ash-rchip__qty");
|
||||
stepper.Add(value);
|
||||
|
||||
Button plus = new Button(() => onDelta(1)) { text = "+" };
|
||||
plus.AddToClassList("ash-rchip__step");
|
||||
stepper.Add(plus);
|
||||
|
||||
return stepper;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a large "+" operator glyph between cost cards.
|
||||
/// </summary>
|
||||
private static Label Operator(string glyph)
|
||||
{
|
||||
Label op = new Label(glyph);
|
||||
op.AddToClassList("ash-equation__op");
|
||||
return op;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Cost Mutations
|
||||
|
||||
/// <summary>
|
||||
/// Sets the item of an existing cost line and repaints.
|
||||
/// </summary>
|
||||
private void SetCostItem(int index, ItemData item)
|
||||
{
|
||||
if (index < 0 || index >= costProp.arraySize) return;
|
||||
costProp.GetArrayElementAtIndex(index).FindPropertyRelative("item").objectReferenceValue = item;
|
||||
so.ApplyModifiedProperties();
|
||||
RefreshCost();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Changes a cost line's quantity by a delta, clamped to a minimum of one, and repaints.
|
||||
/// </summary>
|
||||
private void AdjustCostQuantity(int index, int delta)
|
||||
{
|
||||
if (index < 0 || index >= costProp.arraySize) return;
|
||||
SerializedProperty quantity = costProp.GetArrayElementAtIndex(index).FindPropertyRelative("quantity");
|
||||
quantity.intValue = Mathf.Max(1, quantity.intValue + delta);
|
||||
so.ApplyModifiedProperties();
|
||||
RefreshCost();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Appends a new cost line (quantity 1) for the picked item and repaints; ignores a null pick
|
||||
/// (e.g. the picker was cancelled).
|
||||
/// </summary>
|
||||
private void AddCost(ItemData item)
|
||||
{
|
||||
if (item == null) return;
|
||||
|
||||
int index = costProp.arraySize;
|
||||
costProp.arraySize++;
|
||||
SerializedProperty element = costProp.GetArrayElementAtIndex(index);
|
||||
element.FindPropertyRelative("item").objectReferenceValue = item;
|
||||
element.FindPropertyRelative("quantity").intValue = 1;
|
||||
so.ApplyModifiedProperties();
|
||||
RefreshCost();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes the cost line at the given index and repaints.
|
||||
/// </summary>
|
||||
private void RemoveCost(int index)
|
||||
{
|
||||
if (index < 0 || index >= costProp.arraySize) return;
|
||||
costProp.DeleteArrayElementAtIndex(index);
|
||||
so.ApplyModifiedProperties();
|
||||
RefreshCost();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Item Picker
|
||||
|
||||
/// <summary>
|
||||
/// Builds the hidden IMGUI proxy that relays Unity's object-picker commands to the active cost
|
||||
/// pick handler (the editor window is UI Toolkit, which can't receive those commands directly).
|
||||
/// </summary>
|
||||
private VisualElement BuildPickerProxy()
|
||||
{
|
||||
IMGUIContainer proxy = new IMGUIContainer(HandleItemPickerCommands);
|
||||
proxy.style.position = Position.Absolute;
|
||||
proxy.style.width = 1;
|
||||
proxy.style.height = 1;
|
||||
return proxy;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens Unity's item picker seeded with the current item. When <paramref name="commitLive"/>
|
||||
/// is true the pick applies on every highlight (live preview, e.g. changing an existing line);
|
||||
/// otherwise it applies only when the picker closes (e.g. adding a new line — commit once).
|
||||
/// </summary>
|
||||
private void OpenItemPicker(ItemData seed, bool commitLive, Action<ItemData> onPicked)
|
||||
{
|
||||
costPickHandler = onPicked;
|
||||
costPickCommitsOnCloseOnly = !commitLive;
|
||||
EditorGUIUtility.ShowObjectPicker<ItemData>(seed, false, string.Empty, CostPickerControlId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forwards the picker's selection to the active handler, honouring the live-vs-on-close policy.
|
||||
/// </summary>
|
||||
private void HandleItemPickerCommands()
|
||||
{
|
||||
Event evt = Event.current;
|
||||
if (evt == null || evt.type != EventType.ExecuteCommand) return;
|
||||
if (EditorGUIUtility.GetObjectPickerControlID() != CostPickerControlId) return;
|
||||
|
||||
bool updated = evt.commandName == "ObjectSelectorUpdated";
|
||||
bool closed = evt.commandName == "ObjectSelectorClosed";
|
||||
if (!updated && !closed) return;
|
||||
if (updated && costPickCommitsOnCloseOnly) return;
|
||||
|
||||
costPickHandler?.Invoke(EditorGUIUtility.GetObjectPickerObject() as ItemData);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user