(Feat) Add Build

This commit is contained in:
2026-07-22 12:56:13 +02:00
parent 3657b870d8
commit 0052b0d98e
244 changed files with 35752 additions and 3112 deletions
@@ -0,0 +1,121 @@
using System.Diagnostics;
using UnityEditor;
using UnityEngine;
using UnityEngine.Profiling;
using Ashwild.Building;
using Debug = UnityEngine.Debug;
namespace Ashwild.EditorTools
{
/// <summary>
/// A load test for the decision to spawn every build as a NetworkObject: places a grid of structures
/// and reports how long it took and what it cost in memory, so the "can FishNet carry a whole base"
/// question can be answered with numbers instead of estimates.
///
/// Deliberately single-instance. Measuring a second client's join bandwidth would mean two peers, and
/// the session is wired to FishySteamworks — that costs far more setup than the risk it covers. Join
/// traffic is a one-shot cost of roughly a spawn packet per object anyway; what actually matters day
/// to day is the steady state, and that is exactly what this measures. Watch the profiler's frame time
/// after the spawn, not just the numbers logged here.
///
/// Caveat when reading the elapsed time: this places everything in a single frame, which no player
/// ever does. Treat it as a worst case for the spawn burst, not as a gameplay measurement.
/// </summary>
public static class BuildStressTest
{
#region Constants
private const float Spacing = 4f;
private const float GroundHeight = 0f;
#endregion
#region Menu
/// <summary>
/// Places 250 structures — a realistic co-op base.
/// </summary>
[MenuItem("Tools/Ashwild/Stress Test/Spawn 250 Builds")]
public static void Spawn250() => SpawnGrid(250);
/// <summary>
/// Places 1000 structures — an ambitious long-save base, the level the NetworkObject decision was
/// judged against.
/// </summary>
[MenuItem("Tools/Ashwild/Stress Test/Spawn 1000 Builds")]
public static void Spawn1000() => SpawnGrid(1000);
#endregion
#region Internal Helpers
/// <summary>
/// Commits <paramref name="count"/> builds of the first usable buildable on a grid around the
/// origin, then logs the elapsed time and the memory delta. Requires play mode with a live session,
/// since committing goes through the registry's server RPC exactly as a real placement would.
/// </summary>
private static void SpawnGrid(int count)
{
if (!Application.isPlaying)
{
Debug.LogError("[BuildStressTest] Enter play mode and host a session first — builds are spawned through the server.");
return;
}
if (BuildRegistry.Instance == null)
{
Debug.LogError("[BuildStressTest] No BuildRegistry in the session — is the scene's BuildRegistry present and the session started?");
return;
}
if (!TryResolveBuildable(out ushort id, out string label)) return;
int side = Mathf.CeilToInt(Mathf.Sqrt(count));
long memoryBefore = Profiler.GetTotalAllocatedMemoryLong();
Stopwatch watch = Stopwatch.StartNew();
for (int i = 0; i < count; i++)
{
Vector3 position = new Vector3((i % side) * Spacing, GroundHeight, (i / side) * Spacing);
BuildRegistry.Instance.RequestBuild(id, position, Quaternion.identity);
}
watch.Stop();
long memoryDelta = Profiler.GetTotalAllocatedMemoryLong() - memoryBefore;
Debug.Log($"[BuildStressTest] Requested {count}× '{label}' in {watch.ElapsedMilliseconds} ms " +
$"({memoryDelta / 1024f / 1024f:F1} MB allocated this frame). " +
"Spawns complete over the next frames — check the profiler's steady-state frame time now.");
}
/// <summary>
/// Picks the first buildable that can actually be spawned, so the test never fails halfway on a
/// half-authored asset. Reports clearly when the database is empty or unbuilt.
/// </summary>
private static bool TryResolveBuildable(out ushort id, out string label)
{
id = 0;
label = string.Empty;
BuildableDatabase database = BuildableDatabase.Instance;
if (database == null || database.Buildables == null || database.Buildables.Length == 0)
{
Debug.LogError("[BuildStressTest] BuildableDatabase is empty — run Tools ▸ Ashwild ▸ Rebuild Buildable Database.");
return false;
}
foreach (BuildableData buildable in database.Buildables)
{
if (buildable == null || buildable.BuiltPrefab == null) continue;
id = database.GetId(buildable);
label = buildable.name;
return true;
}
Debug.LogError("[BuildStressTest] No buildable with a BuiltPrefab found.");
return false;
}
#endregion
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: dfaebf3f0f14ca94e803a73cc00965f7
@@ -0,0 +1,109 @@
using UnityEditor;
using UnityEngine;
using Ashwild.Building;
using FishNet.Object;
namespace Ashwild.EditorTools
{
/// <summary>
/// One-shot migration for the move to server-spawned builds: every committed structure is now a real
/// NetworkObject, so each buildable's BuiltPrefab needs a NetworkObject and a BuiltStructure on its
/// root. Prefabs authored before that change have neither (or only BuiltStructure, which is now a
/// NetworkBehaviour and cannot function alone), and a missing NetworkObject makes the prefab silently
/// unspawnable — the exact failure that used to leave a placed chest deactivated.
///
/// Safe to re-run: it only touches prefabs that are actually missing a component, and reports what it
/// changed. Kept as an explicit menu action rather than an automatic postprocessor because it rewrites
/// authored assets, which should never happen behind the designer's back.
/// </summary>
public static class BuiltPrefabMigrator
{
#region Menu
/// <summary>
/// Scans every BuildableData, ensures its BuiltPrefab carries NetworkObject + BuiltStructure, and
/// asks FishNet to rescan its spawnable prefab collection afterwards.
/// </summary>
[MenuItem("Tools/Ashwild/Migrate Built Prefabs To Network Objects")]
public static void Migrate()
{
string[] guids = AssetDatabase.FindAssets("t:BuildableData");
int migrated = 0;
int alreadyFine = 0;
int missingPrefab = 0;
foreach (string guid in guids)
{
BuildableData buildable = AssetDatabase.LoadAssetAtPath<BuildableData>(AssetDatabase.GUIDToAssetPath(guid));
if (buildable == null) continue;
if (buildable.BuiltPrefab == null)
{
Debug.LogWarning($"[BuiltPrefabMigrator] '{buildable.name}' has no BuiltPrefab — skipped.", buildable);
missingPrefab++;
continue;
}
if (EnsureNetworked(buildable.BuiltPrefab)) migrated++;
else alreadyFine++;
}
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
RefreshNetworkPrefabRegistry();
Debug.Log($"[BuiltPrefabMigrator] Done — {migrated} prefab(s) migrated, {alreadyFine} already correct, {missingPrefab} buildable(s) without a BuiltPrefab.");
}
#endregion
#region Internal Helpers
/// <summary>
/// Adds whatever the prefab root is missing and saves it. The NetworkObject goes on first so that
/// BuiltStructure's RequireComponent is already satisfied when it is added. Returns true when the
/// prefab was actually changed.
/// </summary>
private static bool EnsureNetworked(GameObject prefab)
{
string path = AssetDatabase.GetAssetPath(prefab);
if (string.IsNullOrEmpty(path)) return false;
GameObject root = PrefabUtility.LoadPrefabContents(path);
bool changed = false;
if (root.GetComponent<NetworkObject>() == null)
{
root.AddComponent<NetworkObject>();
changed = true;
}
if (root.GetComponent<BuiltStructure>() == null)
{
root.AddComponent<BuiltStructure>();
changed = true;
}
if (changed)
{
PrefabUtility.SaveAsPrefabAsset(root, path);
Debug.Log($"[BuiltPrefabMigrator] Migrated '{path}'.", AssetDatabase.LoadAssetAtPath<GameObject>(path));
}
PrefabUtility.UnloadPrefabContents(root);
return changed;
}
/// <summary>
/// Forces FishNet to rescan DefaultPrefabObjects so the migrated prefabs are spawnable. Invoked
/// through the menu item because the generator API is internal to the FishNet assembly.
/// </summary>
private static void RefreshNetworkPrefabRegistry()
{
const string menu = "Tools/Fish-Networking/Utility/Refresh Default Prefabs";
if (!EditorApplication.ExecuteMenuItem(menu))
Debug.LogWarning($"[BuiltPrefabMigrator] Could not run '{menu}' — run it by hand so the built prefabs are registered as spawnable.");
}
#endregion
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c1d18a05426439d47937c24568677913
@@ -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
}
}
@@ -0,0 +1,213 @@
using System;
using System.Reflection;
using UnityEditor;
using UnityEngine;
namespace Ashwild.EditorTools
{
/// <summary>
/// Editor-only capture helper: an on/off toggle under Tools ▸ Ashwild that, when enabled, forces the
/// Game view into a borderless fullscreen window as soon as Play Mode starts — so the running game
/// looks exactly like a shipped build (no editor chrome, no toolbar) and is clean to record in OBS.
/// The preference is checkable in the menu and persists per-machine in EditorPrefs; entering fullscreen
/// spawns a dedicated popup Game view on the main display and it is torn down the moment play stops.
/// </summary>
[InitializeOnLoad]
public static class FullscreenOnPlay
{
#region Constants
/// <summary>
/// The checkable menu entry that flips the feature on and off.
/// </summary>
private const string MenuPath = "Tools/Fullscreen On Play (OBS)";
/// <summary>
/// EditorPrefs key holding the toggle state. Per-user, per-machine — a personal recording setting,
/// deliberately not shared through the project.
/// </summary>
private const string PrefKey = "Ashwild.FullscreenOnPlay.Enabled";
#endregion
#region State
/// <summary>
/// The live borderless Game view spawned for fullscreen playback, or null when not in fullscreen.
/// Held so it can be closed again the instant Play Mode ends.
/// </summary>
private static EditorWindow fullscreenView;
#endregion
#region Initialization
/// <summary>
/// Hooks the Play Mode state stream once, on editor load and every domain reload, so the fullscreen
/// window follows play/stop regardless of how the user triggered it.
/// </summary>
static FullscreenOnPlay()
{
EditorApplication.playModeStateChanged -= HandlePlayModeStateChanged;
EditorApplication.playModeStateChanged += HandlePlayModeStateChanged;
}
#endregion
#region Menu
/// <summary>
/// Flips the toggle and, as a convenience, applies it immediately when already in Play Mode
/// (goes fullscreen right away, or drops back to the windowed Game view).
/// </summary>
[MenuItem(MenuPath, false, 200)]
private static void Toggle()
{
bool enabled = !IsEnabled;
EditorPrefs.SetBool(PrefKey, enabled);
Menu.SetChecked(MenuPath, enabled);
if (!EditorApplication.isPlaying) return;
if (enabled) EnterFullscreen();
else ExitFullscreen();
}
/// <summary>
/// Keeps the menu checkmark in sync with the stored preference every time the menu is opened.
/// </summary>
[MenuItem(MenuPath, true)]
private static bool ToggleValidate()
{
Menu.SetChecked(MenuPath, IsEnabled);
return true;
}
#endregion
#region Play Mode Hook
/// <summary>
/// Enters fullscreen when play begins (only if the toggle is on) and always tears it down as play
/// ends — so a leftover popup can never survive back into edit mode.
/// </summary>
private static void HandlePlayModeStateChanged(PlayModeStateChange state)
{
switch (state)
{
case PlayModeStateChange.EnteredPlayMode:
if (IsEnabled) EnterFullscreen();
break;
case PlayModeStateChange.ExitingPlayMode:
ExitFullscreen();
break;
}
}
#endregion
#region Fullscreen Control
/// <summary>
/// Spawns a dedicated Game view, strips its toolbar and shows it as a borderless popup sized to the
/// main display — the closest the editor gets to a real build's fullscreen. The position is
/// re-asserted next editor tick because ShowPopup can clamp the initial rect. Reuses nothing from
/// the docked layout, so the user's window arrangement is left untouched.
/// </summary>
private static void EnterFullscreen()
{
if (fullscreenView != null) return;
Type gameViewType = typeof(EditorWindow).Assembly.GetType("UnityEditor.GameView");
if (gameViewType == null)
{
Debug.LogError("[FullscreenOnPlay] Could not resolve UnityEditor.GameView — fullscreen capture unavailable on this Unity version.");
return;
}
fullscreenView = ScriptableObject.CreateInstance(gameViewType) as EditorWindow;
if (fullscreenView == null)
{
Debug.LogError("[FullscreenOnPlay] Failed to create a Game view instance — fullscreen capture aborted.");
return;
}
fullscreenView.titleContent = new GUIContent("Game (Fullscreen)");
SetShowToolbar(fullscreenView, false);
Rect fullscreen = MainDisplayRect();
fullscreenView.ShowPopup();
fullscreenView.position = fullscreen;
fullscreenView.minSize = fullscreen.size;
fullscreenView.maxSize = fullscreen.size;
fullscreenView.Focus();
EditorApplication.delayCall += ReassertPosition;
Debug.Log("[FullscreenOnPlay] Game view is fullscreen for capture. To exit: press your Play shortcut (Ctrl/Cmd+P), or Alt+Tab back to the Unity editor and press Stop.");
}
/// <summary>
/// Closes the fullscreen popup if one is open. Safe to call when nothing is showing (a no-op),
/// so both the play-stop hook and the live toggle can lean on it unconditionally.
/// </summary>
private static void ExitFullscreen()
{
EditorApplication.delayCall -= ReassertPosition;
if (fullscreenView == null) return;
fullscreenView.Close();
fullscreenView = null;
}
/// <summary>
/// Re-applies the fullscreen rect one tick after showing, defeating any clamp ShowPopup applied
/// before the window was fully realized. Guards against the window having been closed meanwhile.
/// </summary>
private static void ReassertPosition()
{
EditorApplication.delayCall -= ReassertPosition;
if (fullscreenView == null) return;
fullscreenView.position = MainDisplayRect();
fullscreenView.Focus();
}
#endregion
#region Internal Helpers
/// <summary>
/// The main display's bounds expressed in editor points (pixels ÷ pixelsPerPoint), so the window
/// covers the whole monitor correctly even under OS display scaling on high-DPI screens.
/// </summary>
private static Rect MainDisplayRect()
{
float pixelsPerPoint = Mathf.Max(1f, EditorGUIUtility.pixelsPerPoint);
Resolution res = Screen.currentResolution;
return new Rect(0f, 0f, res.width / pixelsPerPoint, res.height / pixelsPerPoint);
}
/// <summary>
/// Hides (or shows) the Game view's own toolbar via its internal 'showToolbar' property so the
/// capture is pure game with no editor UI. Silently degrades if the property is missing on a
/// future Unity version — a visible toolbar is a cosmetic loss, not a failure.
/// </summary>
private static void SetShowToolbar(EditorWindow gameView, bool visible)
{
PropertyInfo prop = gameView.GetType().GetProperty(
"showToolbar", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
if (prop != null && prop.CanWrite)
prop.SetValue(gameView, visible);
}
/// <summary>
/// Whether the fullscreen-on-play toggle is currently enabled.
/// </summary>
private static bool IsEnabled => EditorPrefs.GetBool(PrefKey, false);
#endregion
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 71eb5c7505bea6d4f862da3606a758f3