Merge remote-tracking branch 'origin/feat/inport-props' into feat/craft-discovery

# Conflicts:
#	Assets/External/Animated PBR Chest Demo/Materials/WoodChest.mat
#	Packages/com.distantlands.cozy.core/Content/Integration/Import for BiRP.unitypackage.meta
#	Packages/com.distantlands.cozy.core/Content/Integration/Import for HDRP.unitypackage.meta
#	Packages/com.distantlands.cozy.core/Content/Integration/Import for URP.unitypackage.meta
This commit is contained in:
2026-07-25 19:42:26 +02:00
954 changed files with 999772 additions and 26193 deletions
@@ -1,121 +0,0 @@
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
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: dfaebf3f0f14ca94e803a73cc00965f7
@@ -1,109 +0,0 @@
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
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: c1d18a05426439d47937c24568677913
@@ -59,11 +59,6 @@
flex-grow: 1;
}
.ash-search {
width: 180px;
margin-right: 6px;
}
/* ── Body split ──────────────────────────────────────────── */
.ash-body {
flex-direction: row;
@@ -81,6 +76,57 @@
flex-grow: 1;
}
/* ── Left-pane filter header (search + type) ─────────────── */
.ash-listheader {
padding-top: 6px;
padding-bottom: 6px;
padding-left: 6px;
padding-right: 6px;
border-bottom-width: 1px;
border-bottom-color: rgb(18, 19, 22);
overflow: hidden;
}
.ash-listsearch {
margin: 0;
width: auto;
flex-grow: 1;
flex-shrink: 1;
min-width: 0;
max-width: 100%;
height: 22px;
border-radius: 4px;
background-color: rgb(46, 47, 52);
border-width: 0;
}
.ash-listsearch .unity-toolbar-search-field__search-button {
background-color: rgba(0, 0, 0, 0);
border-width: 0;
}
.ash-listsearch .unity-base-text-field__input {
background-color: rgba(0, 0, 0, 0);
border-width: 0;
}
.ash-typefilter {
margin-top: 6px;
margin-left: 0;
margin-right: 0;
margin-bottom: 0;
}
.ash-typefilter .unity-base-field__label {
display: none;
}
.ash-typefilter .unity-base-popup-field__input {
border-radius: 4px;
background-color: rgb(46, 47, 52);
border-width: 0;
}
.ash-right {
flex-grow: 1;
padding: 12px;
@@ -277,6 +323,15 @@
margin-bottom: 8px;
}
/* Small italic note clarifying a field inside a card */
.ash-card__hint {
margin-top: 6px;
font-size: 10px;
color: rgb(130, 132, 140);
-unity-font-style: italic;
white-space: normal;
}
.ash-description .unity-text-field__input {
min-height: 48px;
white-space: normal;
@@ -31,6 +31,7 @@ namespace Ashwild.EditorTools
#region Constants
private const string UssPath = "Assets/GAME/Script/Editor/Database/AshwildDatabase.uss";
private const string AllTypesLabel = "All Types";
#endregion
@@ -38,6 +39,7 @@ namespace Ashwild.EditorTools
private Tab activeTab = Tab.Items;
private string searchFilter = string.Empty;
private string itemTypeFilter = AllTypesLabel;
private readonly List<Object> sourceItems = new List<Object>();
private Object selectedAsset;
@@ -51,6 +53,7 @@ namespace Ashwild.EditorTools
private Button itemsTab;
private Button recipesTab;
private Button buildablesTab;
private PopupField<string> typeFilterField;
#endregion
@@ -106,7 +109,8 @@ namespace Ashwild.EditorTools
#region Toolbar
/// <summary>
/// Builds the top toolbar: the two family tabs, a search field, and the database rebuild action.
/// Builds the top toolbar: the three family tabs and the database rebuild action. The search
/// field and type filter live in the left-pane header, directly above the list they filter.
/// </summary>
private VisualElement BuildToolbar()
{
@@ -124,15 +128,6 @@ namespace Ashwild.EditorTools
spacer.AddToClassList("ash-toolbar-spacer");
toolbar.Add(spacer);
ToolbarSearchField search = new ToolbarSearchField();
search.AddToClassList("ash-search");
search.RegisterValueChangedCallback(evt =>
{
searchFilter = evt.newValue ?? string.Empty;
RefreshList();
});
toolbar.Add(search);
Button rebuild = new Button(ItemDatabaseBuilder.Rebuild) { text = "Rebuild DB" };
rebuild.AddToClassList("ash-btn");
rebuild.tooltip = "Re-scan every ItemData and rewrite the network ItemDatabase.";
@@ -175,6 +170,8 @@ namespace Ashwild.EditorTools
recipesTab.EnableInClassList("ash-tab--active", tab == Tab.Recipes);
buildablesTab.EnableInClassList("ash-tab--active", tab == Tab.Buildables);
UpdateTypeFilterVisibility();
selectedAsset = null;
RefreshList();
ShowSelection();
@@ -185,13 +182,16 @@ namespace Ashwild.EditorTools
#region Left Pane
/// <summary>
/// Builds the left pane: a styled list of the active family plus a footer "New" button.
/// Builds the left pane: a filter header (search + item-type filter), a styled list of the
/// active family, and a footer "New" button.
/// </summary>
private VisualElement BuildLeftPane()
{
VisualElement left = new VisualElement();
left.AddToClassList("ash-left");
left.Add(BuildListHeader());
listView = new ListView(sourceItems)
{
fixedItemHeight = 48,
@@ -214,6 +214,58 @@ namespace Ashwild.EditorTools
return left;
}
/// <summary>
/// Builds the header sitting above the list: a search field that filters by name across every
/// family, and an item-type dropdown that narrows the item list to a single type. The type
/// filter is only meaningful for items, so it is hidden on the Recipes and Buildables tabs.
/// </summary>
private VisualElement BuildListHeader()
{
VisualElement header = new VisualElement();
header.AddToClassList("ash-listheader");
ToolbarSearchField search = new ToolbarSearchField();
search.AddToClassList("ash-listsearch");
search.RegisterValueChangedCallback(evt =>
{
searchFilter = evt.newValue ?? string.Empty;
RefreshList();
});
header.Add(search);
List<string> typeChoices = new List<string> { AllTypesLabel };
typeChoices.AddRange(System.Enum.GetNames(typeof(ItemType)));
typeFilterField = new PopupField<string>(typeChoices, 0);
typeFilterField.AddToClassList("ash-typefilter");
typeFilterField.tooltip = "Show only items of the selected type.";
typeFilterField.RegisterValueChangedCallback(evt =>
{
itemTypeFilter = evt.newValue ?? AllTypesLabel;
RefreshList();
});
header.Add(typeFilterField);
return header;
}
/// <summary>
/// Shows the item-type dropdown only on the Items tab and resets it to "all" when leaving, so a
/// stale type filter never silently narrows a list it cannot apply to.
/// </summary>
private void UpdateTypeFilterVisibility()
{
if (typeFilterField == null) return;
bool showForItems = activeTab == Tab.Items;
typeFilterField.style.display = showForItems ? DisplayStyle.Flex : DisplayStyle.None;
if (!showForItems)
{
itemTypeFilter = AllTypesLabel;
typeFilterField.SetValueWithoutNotify(AllTypesLabel);
}
}
/// <summary>
/// Builds the reusable visual for a single list row (icon, name, type tag).
/// </summary>
@@ -251,6 +303,7 @@ namespace Ashwild.EditorTools
{
ItemData item => AshwildUI.TypeColor(item.ItemType),
BuildableData => AshwildUI.BuildableColor,
CraftingRecipe recipe when recipe.RequiredStation != CraftingStationType.None => AshwildUI.StationColor,
_ => new Color(0.5f, 0.52f, 0.58f)
};
@@ -421,6 +474,9 @@ namespace Ashwild.EditorTools
if (!string.IsNullOrEmpty(searchFilter)
&& AssetDisplayName(asset).IndexOf(searchFilter, System.StringComparison.OrdinalIgnoreCase) < 0)
continue;
if (activeTab == Tab.Items && itemTypeFilter != AllTypesLabel
&& asset is ItemData item && item.ItemType.ToString() != itemTypeFilter)
continue;
sourceItems.Add(asset);
}
@@ -465,12 +521,15 @@ namespace Ashwild.EditorTools
}
/// <summary>
/// The short type tag shown on the right of a list row (item type, or "Recipe").
/// The short type tag shown on the right of a list row. A recipe reports the station it is
/// crafted on rather than a flat "Recipe", so scanning the list tells which recipes are bench-
/// locked; hand recipes (station None) keep reading "Recipe".
/// </summary>
private static string AssetTag(Object asset)
{
if (asset is ItemData item) return item.ItemType.ToString();
if (asset is CraftingRecipe) return "Recipe";
if (asset is CraftingRecipe recipe)
return recipe.RequiredStation == CraftingStationType.None ? "Recipe" : recipe.RequiredStation.ToString();
if (asset is BuildableData) return "Buildable";
return string.Empty;
}
@@ -20,6 +20,12 @@ namespace Ashwild.EditorTools
/// </summary>
public static readonly Color BuildableColor = new Color(0.38f, 0.64f, 0.86f);
/// <summary>
/// The accent colour marking a recipe that is locked to a workstation, so the bench-only ones
/// stand out from the plain (hand-craftable) rows at a glance.
/// </summary>
public static readonly Color StationColor = new Color(0.76f, 0.60f, 0.90f);
/// <summary>
/// The accent colour that identifies an item type across the list badges, hero and cards.
/// </summary>
@@ -10,11 +10,13 @@ namespace Ashwild.EditorTools
/// <summary>
/// Editor-only factory that creates ItemData assets and, on demand, builds the world pickup
/// prefab (Pickable + collider on the Pickable layer) and the in-hand prefab (ToolBehaviour +
/// HeldItemOffset on the Tools layer) by wrapping a chosen source object — a model (FBX) or an
/// existing prefab. The source is nested as a child so the designer's authored model stays intact
/// and editable, while the generated root carries the gameplay components and a collider auto-fit
/// to the source's renderers. Every reference is wired back onto the ItemData. Follows §3 of the
/// project rules: world pickups are plain WorldObjects (registry-synced), never NetworkObjects.
/// HeldItemOffset on the Tools layer) as a Prefab Variant of a chosen source prefab. The designer
/// authors one base prefab with its meshes and materials set up (the raw FBX comes in untextured),
/// hands it to the generator, and each generated prefab becomes a variant that carries the gameplay
/// components as overrides on its own root — so re-texturing the base propagates to both variants.
/// A non-prefab source falls back to a plain duplicate. Every reference is wired back onto the
/// ItemData. Follows §3 of the project rules: world pickups are plain WorldObjects (registry-synced),
/// never NetworkObjects.
/// </summary>
public static class ItemAssetFactory
{
@@ -24,6 +26,9 @@ namespace Ashwild.EditorTools
private const string WorldPrefabFolder = "Assets/GAME/Prefabs/Pickable";
private const string HandPrefabFolder = "Assets/GAME/Prefabs/Tools";
private const string WorldPrefabSuffix = "_Pickable";
private const string HandPrefabSuffix = "_Tools";
private const string PickableLayerName = "Pickable";
private const string ToolsLayerName = "Tools";
private const string HarvestableLayerName = "Harvestable";
@@ -65,19 +70,20 @@ namespace Ashwild.EditorTools
#region World Prefab
/// <summary>
/// Builds the world pickup prefab for an item by wrapping the source object: a root on the
/// Pickable layer carries the source as a child, an auto-fitted non-trigger BoxCollider (so the
/// interactor ray — which reads the Pickable off the hit collider's own GameObject — has a
/// target on the root), and the Pickable component wired to this item. The id stays -1; scene
/// instances get a baked id later via Tools ▸ Ashwild ▸ Assign World Object IDs. The finished
/// prefab is assigned back onto ItemData.worldPrefab. Returns the saved prefab, or null on error.
/// Builds the world pickup prefab for an item as a variant of the source prefab: the variant root
/// sits on the Pickable layer, gains an auto-fitted non-trigger BoxCollider (so the interactor ray
/// — which reads the Pickable off the hit collider's own GameObject — has a target on the root),
/// and a Pickable wired to this item. The id stays -1; scene instances get a baked id later via
/// Tools ▸ Ashwild ▸ Setup World Object IDs. The finished prefab is assigned back onto
/// ItemData.worldPrefab. Returns the saved prefab, or null on error.
/// </summary>
public static GameObject GenerateWorldPrefab(ItemData item, GameObject source)
{
if (!ValidateInputs(item, source, "world")) return null;
if (!EnsureFolder(WorldPrefabFolder)) return null;
GameObject root = BuildRoot(item, source, ResolveLayer(PickableLayerName));
string prefabName = item.name + WorldPrefabSuffix;
GameObject root = InstantiateSourceRoot(source, ResolveLayer(PickableLayerName), prefabName);
FitBoxCollider(root);
Pickable pickable = root.AddComponent<Pickable>();
@@ -85,7 +91,7 @@ namespace Ashwild.EditorTools
so.FindProperty("itemData").objectReferenceValue = item;
so.ApplyModifiedPropertiesWithoutUndo();
return SaveAndAssign(root, WorldPrefabFolder, item, "worldPrefab");
return SaveAndAssign(root, WorldPrefabFolder, item, "worldPrefab", prefabName);
}
#endregion
@@ -93,18 +99,19 @@ namespace Ashwild.EditorTools
#region Hand Prefab
/// <summary>
/// Builds the in-hand prefab for a tool/weapon by wrapping the source object: a root on the
/// Tools layer carries the source as a child, a ToolBehaviour (harvest ray masked to the
/// Harvestable layer) and a HeldItemOffset so it can be posed in the holder. No collider —
/// held items carry no physics. The prefab is assigned back onto ItemData.handPrefab.
/// Returns the prefab, or null on error.
/// Builds the in-hand prefab for a tool/weapon as a variant of the source prefab: the variant
/// root sits on the Tools layer and gains a ToolBehaviour (harvest ray masked to the Harvestable
/// layer) and a HeldItemOffset so it can be posed in the holder. No collider — held items carry
/// no physics. The prefab is assigned back onto ItemData.handPrefab. Returns the prefab, or null
/// on error.
/// </summary>
public static GameObject GenerateHandPrefab(ItemData item, GameObject source)
{
if (!ValidateInputs(item, source, "hand")) return null;
if (!EnsureFolder(HandPrefabFolder)) return null;
GameObject root = BuildRoot(item, source, ResolveLayer(ToolsLayerName));
string prefabName = item.name + HandPrefabSuffix;
GameObject root = InstantiateSourceRoot(source, ResolveLayer(ToolsLayerName), prefabName);
ToolBehaviour tool = root.AddComponent<ToolBehaviour>();
SerializedObject so = new SerializedObject(tool);
@@ -113,7 +120,7 @@ namespace Ashwild.EditorTools
root.AddComponent<HeldItemOffset>();
return SaveAndAssign(root, HandPrefabFolder, item, "handPrefab");
return SaveAndAssign(root, HandPrefabFolder, item, "handPrefab", prefabName);
}
#endregion
@@ -139,18 +146,20 @@ namespace Ashwild.EditorTools
}
/// <summary>
/// Creates the generated root named after the item, on the given layer, with the source object
/// (model or prefab) nested as a child at the origin so its authored transform is preserved as
/// a prefab link rather than flattened.
/// Instantiates the source prefab itself as the generated root — keeping the prefab connection so
/// SaveAndAssign turns it into a variant — then applies the target name and layer as variant
/// overrides. A non-prefab source falls back to a plain (unlinked) copy, which SaveAndAssign then
/// saves as a regular prefab rather than a variant.
/// </summary>
private static GameObject BuildRoot(ItemData item, GameObject source, int layer)
private static GameObject InstantiateSourceRoot(GameObject source, int layer, string rootName)
{
GameObject root = new GameObject(item.name) { layer = layer };
GameObject root = PrefabUtility.InstantiatePrefab(source) as GameObject;
if (root == null) root = Object.Instantiate(source);
GameObject visual = (GameObject)PrefabUtility.InstantiatePrefab(source);
if (visual == null) visual = Object.Instantiate(source);
visual.transform.SetParent(root.transform, false);
visual.transform.localPosition = Vector3.zero;
root.name = rootName;
root.layer = layer;
root.transform.position = Vector3.zero;
root.transform.rotation = Quaternion.identity;
return root;
}
@@ -179,12 +188,13 @@ namespace Ashwild.EditorTools
}
/// <summary>
/// Saves a built GameObject as a prefab under a unique path, destroys the scene instance,
/// wires the saved prefab onto the given ItemData field, and refreshes the asset database.
/// Saves a built GameObject as a prefab under a unique path (named with the type-specific suffix
/// so pickables and hand tools stay distinguishable), destroys the scene instance, wires the
/// saved prefab onto the given ItemData field, and refreshes the asset database.
/// </summary>
private static GameObject SaveAndAssign(GameObject root, string folder, ItemData item, string itemField)
private static GameObject SaveAndAssign(GameObject root, string folder, ItemData item, string itemField, string prefabName)
{
string prefabPath = AssetDatabase.GenerateUniqueAssetPath($"{folder}/{item.name}.prefab");
string prefabPath = AssetDatabase.GenerateUniqueAssetPath($"{folder}/{prefabName}.prefab");
GameObject prefab = PrefabUtility.SaveAsPrefabAsset(root, prefabPath);
Object.DestroyImmediate(root);
@@ -1,9 +1,12 @@
using System;
using System.IO;
using System.Text.RegularExpressions;
using UnityEditor;
using UnityEditor.UIElements;
using UnityEngine;
using UnityEngine.UIElements;
using Ashwild.Inventory;
using Ashwild.Player;
namespace Ashwild.EditorTools
{
@@ -37,8 +40,6 @@ namespace Ashwild.EditorTools
private VisualElement maxStackRow;
private VisualElement fuelSecondsRow;
private VisualElement generationCard;
private Button worldGenButton;
private Button handGenButton;
private ObjectField worldField;
private ObjectField handField;
private IMGUIContainer iconPickerProxy;
@@ -70,6 +71,7 @@ namespace Ashwild.EditorTools
Root.Add(BuildCookingCard());
Root.Add(BuildFuelCard());
Root.Add(BuildPrefabsCard());
Root.Add(BuildAnimationCard());
Root.Add(BuildGenerationCard());
currentType = item.ItemType;
@@ -114,6 +116,7 @@ namespace Ashwild.EditorTools
nameField.AddToClassList("ash-hero__name");
nameField.BindProperty(so.FindProperty("itemName"));
nameField.RegisterValueChangedCallback(_ => onMetaChanged?.Invoke());
nameField.RegisterCallback<FocusOutEvent>(_ => RenameAssetToItemName());
info.Add(AshwildUI.EditableNameRow(nameField));
VisualElement typeRow = new VisualElement();
@@ -201,6 +204,50 @@ namespace Ashwild.EditorTools
#endregion
#region Asset Naming
/// <summary>
/// Renames the underlying asset file to match the authored item name (spaces → underscores) so
/// the ScriptableObject on disk reads like the item it holds instead of the generic "NewItem".
/// Runs on focus-out, not per keystroke, so the file is renamed once the name is committed; a
/// blank name, an unchanged name, or a rename collision is skipped (the latter logged).
/// </summary>
private void RenameAssetToItemName()
{
string path = AssetDatabase.GetAssetPath(item);
if (string.IsNullOrEmpty(path)) return;
string desired = ToAssetFileName(item.ItemName);
if (string.IsNullOrEmpty(desired)) return;
if (string.Equals(Path.GetFileNameWithoutExtension(path), desired, StringComparison.Ordinal)) return;
string error = AssetDatabase.RenameAsset(path, desired);
if (!string.IsNullOrEmpty(error))
{
Debug.LogError($"[ItemEditorView] Could not rename asset to '{desired}': {error}", item);
return;
}
onMetaChanged?.Invoke();
}
/// <summary>
/// Turns an authored item name into a valid asset file name: trims, collapses every whitespace
/// run to a single underscore, and drops characters the filesystem forbids in a file name.
/// </summary>
private static string ToAssetFileName(string itemName)
{
if (string.IsNullOrWhiteSpace(itemName)) return string.Empty;
string underscored = Regex.Replace(itemName.Trim(), @"\s+", "_");
foreach (char invalid in Path.GetInvalidFileNameChars())
underscored = underscored.Replace(invalid.ToString(), string.Empty);
return underscored;
}
#endregion
#region Cards
/// <summary>
@@ -330,29 +377,47 @@ namespace Ashwild.EditorTools
}
/// <summary>
/// Generation card: pick a source model/prefab, then build the world pickup (always) and the
/// in-hand prefab (tools/weapons only) wrapped around it.
/// Animation card: the clip sets this item imposes on each rig while it is held. Both are
/// optional — an item that leaves them empty simply keeps the bare-hand animations, which is the
/// right answer for every material and most consumables.
/// </summary>
private VisualElement BuildAnimationCard()
{
VisualElement card = AshwildUI.Card("Animation");
ObjectField armsField = new ObjectField("Arms Set") { objectType = typeof(PlayerAnimationSet), allowSceneObjects = false };
armsField.BindProperty(so.FindProperty("armsAnimationSet"));
card.Add(armsField);
ObjectField bodyField = new ObjectField("Body Set") { objectType = typeof(PlayerAnimationSet), allowSceneObjects = false };
bodyField.BindProperty(so.FindProperty("bodyAnimationSet"));
card.Add(bodyField);
return card;
}
/// <summary>
/// Generation card: pick a source prefab, then build both the world pickup (always) and the
/// in-hand prefab (tools/weapons only) from it with one button, creating only the ones still
/// missing.
/// </summary>
private VisualElement BuildGenerationCard()
{
generationCard = AshwildUI.Card("Prefab Generation");
generationCard.AddToClassList("ash-card--accent");
ObjectField source = new ObjectField("Source (Prefab / Model)") { objectType = typeof(GameObject), allowSceneObjects = false };
ObjectField source = new ObjectField("Source Prefab") { objectType = typeof(GameObject), allowSceneObjects = false };
source.tooltip = "The set-up prefab (meshes + materials). Generation saves a Prefab Variant of it with the gameplay components added.";
source.RegisterValueChangedCallback(evt => pendingSource = evt.newValue as GameObject);
generationCard.Add(source);
VisualElement buttons = new VisualElement();
buttons.AddToClassList("ash-buttons");
worldGenButton = new Button(GenerateWorld) { text = "Generate World Prefab" };
worldGenButton.AddToClassList("ash-btn");
worldGenButton.AddToClassList("ash-btn--primary");
buttons.Add(worldGenButton);
handGenButton = new Button(GenerateHand) { text = "Generate Hand Prefab" };
handGenButton.AddToClassList("ash-btn");
buttons.Add(handGenButton);
Button generate = new Button(GeneratePrefabs) { text = "Generate Prefab" };
generate.AddToClassList("ash-btn");
generate.AddToClassList("ash-btn--primary");
buttons.Add(generate);
generationCard.Add(buttons);
return generationCard;
@@ -363,21 +428,23 @@ namespace Ashwild.EditorTools
#region Actions
/// <summary>
/// Builds the world pickup prefab around the chosen source and re-renders on success.
/// Builds every prefab still missing for the item from the chosen source: the world pickup when
/// none is set, and the in-hand prefab when the item is a tool/weapon and none is set. Re-renders
/// once if anything was generated so the view reflects the new references and hides the card when
/// nothing is left to build.
/// </summary>
private void GenerateWorld()
private void GeneratePrefabs()
{
if (ItemAssetFactory.GenerateWorldPrefab(item, pendingSource) != null)
onPrefabGenerated?.Invoke();
}
bool generated = false;
/// <summary>
/// Builds the in-hand prefab around the chosen source and re-renders on success.
/// </summary>
private void GenerateHand()
{
if (ItemAssetFactory.GenerateHandPrefab(item, pendingSource) != null)
onPrefabGenerated?.Invoke();
if (worldField.value == null)
generated |= ItemAssetFactory.GenerateWorldPrefab(item, pendingSource) != null;
bool isToolLike = currentType == ItemType.Tool || currentType == ItemType.Weapon;
if (isToolLike && handField.value == null)
generated |= ItemAssetFactory.GenerateHandPrefab(item, pendingSource) != null;
if (generated) onPrefabGenerated?.Invoke();
}
#endregion
@@ -398,21 +465,19 @@ namespace Ashwild.EditorTools
}
/// <summary>
/// Hides a generation button once its prefab already exists (and the hand button entirely for
/// non-tools), and collapses the whole generation card when nothing is left to generate — so a
/// fully wired item shows no redundant tooling. Re-evaluated when the type or a prefab field changes.
/// Collapses the whole generation card once every prefab the item needs already exists — the
/// world pickup for any item, plus the hand prefab for tools/weapons — so a fully wired item
/// shows no redundant tooling. Re-evaluated when the type or a prefab field changes.
/// </summary>
private void UpdateGenerationVisibility()
{
if (worldField == null || handField == null || generationCard == null) return;
bool isToolLike = currentType == ItemType.Tool || currentType == ItemType.Weapon;
bool showWorld = worldField.value == null;
bool showHand = isToolLike && handField.value == null;
bool needWorld = worldField.value == null;
bool needHand = isToolLike && handField.value == null;
SetRowVisible(worldGenButton, showWorld);
SetRowVisible(handGenButton, showHand);
SetRowVisible(generationCard, showWorld || showHand);
SetRowVisible(generationCard, needWorld || needHand);
}
/// <summary>
@@ -13,8 +13,8 @@ namespace Ashwild.EditorTools
/// editor IS the recipe equation: a row of interactive ingredient cards ("+"-joined) leading to
/// "=" and the result card. Each card's icon opens an item picker to set/change the item, a
/// /+ stepper adjusts its quantity, and ingredient cards carry a remove button; a dashed "+" tile
/// appends a new ingredient. A compact name field sits above it. All edits write straight to the
/// asset and repaint the strip.
/// appends a new ingredient. A compact name field sits above it, and a station card below states
/// where the recipe can be crafted. All edits write straight to the asset and repaint the strip.
/// </summary>
public class RecipeEditorView
{
@@ -51,6 +51,7 @@ namespace Ashwild.EditorTools
Root = new VisualElement();
Root.Add(BuildNameField());
Root.Add(BuildEquation());
Root.Add(BuildStationCard());
Root.Add(BuildPickerProxy());
RefreshEquation();
@@ -76,6 +77,28 @@ namespace Ashwild.EditorTools
return row;
}
/// <summary>
/// Builds the card stating where the recipe can be crafted. The hint spells out the meaning of
/// None, which reads as "no station" but actually means "craftable everywhere — bare hands and
/// every station alike"; any other value restricts the recipe to that station only.
/// Changing it refreshes the list row, whose tag shows the station.
/// </summary>
private VisualElement BuildStationCard()
{
VisualElement card = AshwildUI.Card("Station");
EnumField stationField = new EnumField("Crafted On", recipe.RequiredStation);
stationField.BindProperty(so.FindProperty("requiredStation"));
stationField.RegisterValueChangedCallback(_ => onMetaChanged?.Invoke());
card.Add(stationField);
Label hint = new Label("None = craftable by hand and on every station.");
hint.AddToClassList("ash-card__hint");
card.Add(hint);
return card;
}
#endregion
#region Equation
@@ -0,0 +1,344 @@
using System.Collections.Generic;
using UnityEditor;
using UnityEditor.Animations;
using UnityEngine;
using Ashwild.Player;
namespace Ashwild.EditorTools
{
/// <summary>
/// Editor tool that builds the master Animator Controller the player rigs run on. The controller is
/// authored once and never edited per weapon: every state points at an empty placeholder clip whose
/// *name* is the slot key, and PlayerAnimationSetBinder swaps those clips at runtime for whatever the
/// held item authors.
///
/// Generating it rather than clicking it together is not a convenience — the contract between the
/// controller and PlayerAnimationSet is a set of exact strings (parameter names, clip names), and a
/// single typo there fails silently: the parameter simply never moves and the rig stands still with
/// no error to explain why. Encoding the contract in code makes it impossible to get wrong, and lets
/// the controller be rebuilt from scratch after any manual experiment.
///
/// Placeholder clips are deliberately empty and stored as their own assets rather than reusing a real
/// animation, so the master controller depends on no FBX and the slot list stays explicit. A slot no
/// set ever fills therefore plays nothing, which reads as an obvious gap instead of a wrong pose.
///
/// Safe to re-run: it rewrites the controller in place, keeping the asset's GUID so every Animator
/// already pointing at it stays wired.
/// </summary>
public static class PlayerAnimatorControllerBuilder
{
#region Constants
private const string AnimationsFolder = "Assets/GAME/Animations/Arms";
private const string SlotsFolder = AnimationsFolder + "/Slots";
private const string ArmsControllerPath = AnimationsFolder + "/PlayerArms.controller";
/// <summary>
/// Blend thresholds for the ground tree. They are the gait levels PlayerAnimatorDriver reports —
/// 0 idle, 1 walk, 2 run — not speeds in metres per second, so retuning how fast the player moves
/// never desynchronises the animation.
///
/// RunClipSpeed exists only while walk and run share one authored clip: with the same motion in
/// both slots, playing the run entry faster is the only thing that distinguishes sprinting from
/// walking. Drop it back to 1 as soon as a real run animation fills the Run slot.
/// </summary>
private const float WalkThreshold = 1f;
private const float RunThreshold = 2f;
private const float RunClipSpeed = 1.5f;
private const string SpeedParam = "Speed";
private const string GroundedParam = "Grounded";
private const string CrouchingParam = "Crouching";
private const string SprintingParam = "Sprinting";
private const string AttackIndexParam = "AttackIndex";
private const string JumpParam = "Jump";
private const string LandParam = "Land";
private const string AttackParam = "Attack";
private const string GrabParam = "Grab";
private const string EquipParam = "Equip";
private const string UnequipParam = "Unequip";
/// <summary>
/// Every clip slot the controller declares, in the order a reader should meet them. These strings
/// are the keys PlayerAnimationSet is queried with — they must match its slot constants exactly.
/// </summary>
private static readonly string[] Slots =
{
PlayerAnimationSet.SlotIdle,
PlayerAnimationSet.SlotWalk,
PlayerAnimationSet.SlotRun,
PlayerAnimationSet.SlotJumpStart,
PlayerAnimationSet.SlotJumpLoop,
PlayerAnimationSet.SlotJumpLand,
PlayerAnimationSet.SlotGrab,
PlayerAnimationSet.SlotEquip,
PlayerAnimationSet.SlotUnequip,
PlayerAnimationSet.SlotAttackPrefix + "1",
};
#endregion
#region Menu
/// <summary>
/// Builds (or rebuilds) the first-person arms controller and the placeholder clips it references.
/// </summary>
[MenuItem("Tools/Ashwild/Build Player Arms Controller")]
public static void BuildArmsController()
{
EnsureFolders();
Dictionary<string, AnimationClip> placeholders = new Dictionary<string, AnimationClip>();
foreach (string slot in Slots)
placeholders[slot] = GetOrCreatePlaceholder(slot);
AnimatorController controller = GetOrCreateController(ArmsControllerPath);
ClearController(controller);
AddParameters(controller);
BuildLocomotionLayer(controller, placeholders);
BuildActionLayer(controller, placeholders);
EditorUtility.SetDirty(controller);
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
Debug.Log($"[PlayerAnimatorControllerBuilder] Built {ArmsControllerPath} with {Slots.Length} clip slots. " +
"Assign it to the arms Animator, then point PlayerAnimationSetBinder at your Arms_NoItem set.",
controller);
Selection.activeObject = controller;
}
#endregion
#region Layers
/// <summary>
/// Base layer: the movement the player is always doing. A 1D blend tree covers ground movement so
/// idle and run ease into each other instead of snapping, and the jump chain is a straight line
/// (start → loop → land) driven by the Grounded flag rather than by timers, so a fall the player
/// never jumped into still enters the loop from Any State.
/// </summary>
private static void BuildLocomotionLayer(AnimatorController controller, Dictionary<string, AnimationClip> clips)
{
AnimatorControllerLayer[] layers = controller.layers;
layers[0].name = "Locomotion";
controller.layers = layers;
AnimatorStateMachine machine = controller.layers[0].stateMachine;
BlendTree tree;
AnimatorState locomotion = controller.CreateBlendTreeInController("Locomotion", out tree, 0);
tree.blendType = BlendTreeType.Simple1D;
tree.blendParameter = SpeedParam;
tree.useAutomaticThresholds = false;
tree.AddChild(clips[PlayerAnimationSet.SlotIdle], 0f);
tree.AddChild(clips[PlayerAnimationSet.SlotWalk], WalkThreshold);
tree.AddChild(clips[PlayerAnimationSet.SlotRun], RunThreshold);
SetChildSpeed(tree, 2, RunClipSpeed);
AnimatorState jumpStart = machine.AddState(PlayerAnimationSet.SlotJumpStart);
jumpStart.motion = clips[PlayerAnimationSet.SlotJumpStart];
AnimatorState jumpLoop = machine.AddState(PlayerAnimationSet.SlotJumpLoop);
jumpLoop.motion = clips[PlayerAnimationSet.SlotJumpLoop];
AnimatorState jumpLand = machine.AddState(PlayerAnimationSet.SlotJumpLand);
jumpLand.motion = clips[PlayerAnimationSet.SlotJumpLand];
machine.defaultState = locomotion;
AnimatorStateTransition toJump = locomotion.AddTransition(jumpStart);
toJump.hasExitTime = false;
toJump.duration = 0.05f;
toJump.AddCondition(AnimatorConditionMode.If, 0f, JumpParam);
AnimatorStateTransition startToLoop = jumpStart.AddTransition(jumpLoop);
startToLoop.hasExitTime = true;
startToLoop.exitTime = 0.8f;
startToLoop.duration = 0.1f;
AnimatorStateTransition anyToLoop = machine.AddAnyStateTransition(jumpLoop);
anyToLoop.hasExitTime = false;
anyToLoop.duration = 0.15f;
anyToLoop.canTransitionToSelf = false;
anyToLoop.AddCondition(AnimatorConditionMode.IfNot, 0f, GroundedParam);
AnimatorStateTransition loopToLand = jumpLoop.AddTransition(jumpLand);
loopToLand.hasExitTime = false;
loopToLand.duration = 0.1f;
loopToLand.AddCondition(AnimatorConditionMode.If, 0f, GroundedParam);
AnimatorStateTransition landToLocomotion = jumpLand.AddTransition(locomotion);
landToLocomotion.hasExitTime = true;
landToLocomotion.exitTime = 0.7f;
landToLocomotion.duration = 0.15f;
}
/// <summary>
/// Action layer: the one-shots that play *over* whatever the legs are doing. It sits on an empty
/// default state at full weight, so it contributes nothing until an action fires and the player
/// keeps running normally underneath. Every action returns to that empty state on exit time,
/// which is what lets a swing interrupt itself cleanly on the next click.
/// </summary>
private static void BuildActionLayer(AnimatorController controller, Dictionary<string, AnimationClip> clips)
{
controller.AddLayer("Action");
AnimatorControllerLayer[] layers = controller.layers;
AnimatorControllerLayer action = layers[1];
action.defaultWeight = 1f;
action.blendingMode = AnimatorLayerBlendingMode.Override;
controller.layers = layers;
AnimatorStateMachine machine = action.stateMachine;
AnimatorState none = machine.AddState("None");
machine.defaultState = none;
AddOneShot(machine, none, PlayerAnimationSet.SlotGrab, clips, GrabParam);
AddOneShot(machine, none, PlayerAnimationSet.SlotEquip, clips, EquipParam);
AddOneShot(machine, none, PlayerAnimationSet.SlotUnequip, clips, UnequipParam);
AddOneShot(machine, none, PlayerAnimationSet.SlotAttackPrefix + "1", clips, AttackParam);
}
/// <summary>
/// Wires one action: entered from Any State on its trigger so it can fire at any moment (and
/// re-fire while already playing, which a combo needs), and released back to the empty state on
/// exit time so the layer stops contributing as soon as the action is over.
/// </summary>
private static void AddOneShot(AnimatorStateMachine machine, AnimatorState none, string slot,
Dictionary<string, AnimationClip> clips, string trigger)
{
AnimatorState state = machine.AddState(slot);
state.motion = clips[slot];
AnimatorStateTransition enter = machine.AddAnyStateTransition(state);
enter.hasExitTime = false;
enter.duration = 0.05f;
enter.canTransitionToSelf = true;
enter.AddCondition(AnimatorConditionMode.If, 0f, trigger);
AnimatorStateTransition exit = state.AddTransition(none);
exit.hasExitTime = true;
exit.exitTime = 0.9f;
exit.duration = 0.1f;
}
#endregion
#region Internal Helpers
/// <summary>
/// Sets one blend-tree child's playback rate. The children array must be reassigned wholesale
/// because BlendTree.children hands back a copy — mutating the returned struct in place silently
/// does nothing.
/// </summary>
private static void SetChildSpeed(BlendTree tree, int index, float speed)
{
ChildMotion[] children = tree.children;
if (index < 0 || index >= children.Length) return;
children[index].timeScale = speed;
tree.children = children;
}
/// <summary>
/// Declares every parameter PlayerAnimatorDriver writes. Names are the ones the driver defaults
/// to, so a freshly added driver works with no inspector edits.
/// </summary>
private static void AddParameters(AnimatorController controller)
{
controller.AddParameter(SpeedParam, AnimatorControllerParameterType.Float);
controller.AddParameter(GroundedParam, AnimatorControllerParameterType.Bool);
controller.AddParameter(CrouchingParam, AnimatorControllerParameterType.Bool);
controller.AddParameter(SprintingParam, AnimatorControllerParameterType.Bool);
controller.AddParameter(AttackIndexParam, AnimatorControllerParameterType.Int);
controller.AddParameter(JumpParam, AnimatorControllerParameterType.Trigger);
controller.AddParameter(LandParam, AnimatorControllerParameterType.Trigger);
controller.AddParameter(AttackParam, AnimatorControllerParameterType.Trigger);
controller.AddParameter(GrabParam, AnimatorControllerParameterType.Trigger);
controller.AddParameter(EquipParam, AnimatorControllerParameterType.Trigger);
controller.AddParameter(UnequipParam, AnimatorControllerParameterType.Trigger);
SetDefaultBool(controller, GroundedParam, true);
}
/// <summary>
/// Seeds a bool's authored default so the rig starts in a sane pose on the very first frame,
/// before the driver has pushed anything — a player spawning "not grounded" would otherwise flash
/// the fall loop.
/// </summary>
private static void SetDefaultBool(AnimatorController controller, string paramName, bool value)
{
AnimatorControllerParameter[] parameters = controller.parameters;
for (int i = 0; i < parameters.Length; i++)
{
if (parameters[i].name != paramName) continue;
parameters[i].defaultBool = value;
break;
}
controller.parameters = parameters;
}
/// <summary>
/// Empties an existing controller so a rebuild never stacks duplicate states or parameters on top
/// of the previous run. The asset itself is kept so its GUID — and every Animator reference to
/// it — survives.
/// </summary>
private static void ClearController(AnimatorController controller)
{
for (int i = controller.layers.Length - 1; i > 0; i--)
controller.RemoveLayer(i);
while (controller.parameters.Length > 0)
controller.RemoveParameter(0);
AnimatorStateMachine machine = controller.layers[0].stateMachine;
for (int i = machine.states.Length - 1; i >= 0; i--)
machine.RemoveState(machine.states[i].state);
for (int i = machine.anyStateTransitions.Length - 1; i >= 0; i--)
machine.RemoveAnyStateTransition(machine.anyStateTransitions[i]);
}
/// <summary>
/// Loads the controller at a path, creating it on first run.
/// </summary>
private static AnimatorController GetOrCreateController(string path)
{
AnimatorController existing = AssetDatabase.LoadAssetAtPath<AnimatorController>(path);
return existing != null ? existing : AnimatorController.CreateAnimatorControllerAtPath(path);
}
/// <summary>
/// Loads (or creates) the empty clip that stands in for a slot. Its name is the slot key the
/// binder matches sets against, which is the whole reason these exist as named assets.
/// </summary>
private static AnimationClip GetOrCreatePlaceholder(string slot)
{
string path = $"{SlotsFolder}/{slot}.anim";
AnimationClip existing = AssetDatabase.LoadAssetAtPath<AnimationClip>(path);
if (existing != null) return existing;
AnimationClip clip = new AnimationClip { name = slot };
AssetDatabase.CreateAsset(clip, path);
return clip;
}
/// <summary>
/// Makes sure the target folders exist before anything is written into them.
/// </summary>
private static void EnsureFolders()
{
if (!AssetDatabase.IsValidFolder(AnimationsFolder))
{
Debug.LogError($"[PlayerAnimatorControllerBuilder] '{AnimationsFolder}' does not exist — create it first.");
return;
}
if (!AssetDatabase.IsValidFolder(SlotsFolder))
AssetDatabase.CreateFolder(AnimationsFolder, "Slots");
}
#endregion
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 17862d3b35172c644928bc3acaebe6e5
@@ -45,6 +45,16 @@ namespace Ashwild.EditorTools
#endregion
#region Subject Config
/// <summary>
/// The subject's own local rotation (Euler degrees), independent of the camera orbit. Persisted
/// on the stage so it survives target swaps, and re-applied whenever a new subject loads.
/// </summary>
public Vector3 SubjectEuler;
#endregion
#region Lighting Config
public Color KeyColor = Color.white;
@@ -187,7 +197,7 @@ namespace Ashwild.EditorTools
targetInstance.hideFlags = HideFlags.HideAndDontSave;
targetInstance.transform.position = Vector3.zero;
targetInstance.transform.rotation = Quaternion.identity;
targetInstance.transform.rotation = Quaternion.Euler(SubjectEuler);
SceneManager.MoveGameObjectToScene(targetInstance, scene);
ComputeBounds();
@@ -253,6 +263,20 @@ namespace Ashwild.EditorTools
/// </summary>
public void FrameTarget() => Zoom = 1f;
/// <summary>
/// Re-poses the subject to the given local Euler rotation and recomputes its framing bounds, so
/// the camera keeps pivoting on the rotated silhouette's centre. No-op (but the value is still
/// remembered) when no subject is loaded.
/// </summary>
public void SetSubjectRotation(Vector3 euler)
{
SubjectEuler = euler;
if (targetInstance == null) return;
targetInstance.transform.rotation = Quaternion.Euler(euler);
ComputeBounds();
}
/// <summary>
/// The world-space radius of the subject's bounding sphere, clamped to a small minimum so a
/// zero-size target still frames sanely.
@@ -181,8 +181,8 @@ namespace Ashwild.EditorTools
/// <summary>
/// Target card: a Prefab/Mesh kind selector that filters the object picker to only those assets
/// (so materials, scripts and the like never clutter it), the object field itself, plus quick
/// Frame and auto-rotate controls that act on the loaded subject.
/// (so materials, scripts and the like never clutter it), the object field itself, per-axis
/// rotation sliders that pose the subject, plus quick Frame and auto-rotate controls.
/// </summary>
private VisualElement BuildTargetCard()
{
@@ -206,6 +206,13 @@ namespace Ashwild.EditorTools
card.Add(kind);
card.Add(field);
card.Add(SliderRow("Rotate X", 0f, 360f, stage.SubjectEuler.x,
v => stage.SetSubjectRotation(new Vector3(v, stage.SubjectEuler.y, stage.SubjectEuler.z))));
card.Add(SliderRow("Rotate Y", 0f, 360f, stage.SubjectEuler.y,
v => stage.SetSubjectRotation(new Vector3(stage.SubjectEuler.x, v, stage.SubjectEuler.z))));
card.Add(SliderRow("Rotate Z", 0f, 360f, stage.SubjectEuler.z,
v => stage.SetSubjectRotation(new Vector3(stage.SubjectEuler.x, stage.SubjectEuler.y, v))));
VisualElement buttons = new VisualElement();
buttons.AddToClassList("ss-buttons");
@@ -213,6 +220,10 @@ namespace Ashwild.EditorTools
frame.AddToClassList("ss-btn");
buttons.Add(frame);
Button resetRotation = new Button(() => { stage.SetSubjectRotation(Vector3.zero); RebuildControls(); RenderPreview(); }) { text = "Reset Rotation" };
resetRotation.AddToClassList("ss-btn");
buttons.Add(resetRotation);
Toggle rotate = new Toggle("Auto-rotate") { value = autoRotate };
rotate.RegisterValueChangedCallback(evt => SetAutoRotate(evt.newValue));
buttons.Add(rotate);
@@ -355,7 +366,10 @@ namespace Ashwild.EditorTools
/// <summary>
/// Builds the output-folder row: a read-only path label and a Browse button that opens a folder
/// picker and remembers the choice in EditorPrefs.
/// picker and remembers the choice in EditorPrefs. The picker starts from the resolved export
/// folder (normalised to native separators) so it opens where the label points — the native
/// Windows dialog ignores forward-slash paths like Application.dataPath and would otherwise fall
/// back to the shell's last-used location (often another project entirely).
/// </summary>
private VisualElement BuildFolderRow()
{
@@ -364,11 +378,12 @@ namespace Ashwild.EditorTools
Label path = new Label(ShortFolder());
path.AddToClassList("ss-path");
path.tooltip = exportFolder;
path.tooltip = ResolveFolder();
Button browse = new Button(() =>
{
string chosen = EditorUtility.OpenFolderPanel("Export folder", exportFolder, string.Empty);
string start = ResolveFolder().Replace('/', Path.DirectorySeparatorChar);
string chosen = EditorUtility.OpenFolderPanel("Export folder", start, string.Empty);
if (string.IsNullOrEmpty(chosen)) return;
exportFolder = chosen;
EditorPrefs.SetString(FolderPrefKey, exportFolder);
@@ -627,8 +642,8 @@ namespace Ashwild.EditorTools
/// <summary>
/// Captures the current view at the export resolution and writes it as a PNG to the chosen folder,
/// avoiding overwrites by auto-numbering, refreshing the AssetDatabase when the path is in-project,
/// and revealing the file. Guards a missing target and failed writes with clear logs.
/// avoiding overwrites by auto-numbering, importing it as a single-mode Sprite when the path is
/// in-project, and revealing the file. Guards a missing target and failed writes with clear logs.
/// </summary>
private void ExportPng()
{
@@ -656,7 +671,7 @@ namespace Ashwild.EditorTools
}
EditorPrefs.SetString(FilePrefKey, exportFileName);
RefreshIfInProject(path);
ImportAsSprite(path);
Debug.Log($"[ScreenshotStudio] Exported {exportWidth}×{exportHeight} PNG → {path}");
ShowNotification(new GUIContent($"Exported {Path.GetFileName(path)}"));
}
@@ -676,6 +691,7 @@ namespace Ashwild.EditorTools
string folder = ResolveFolder();
string baseName = Path.GetFileNameWithoutExtension(ResolveFileName());
float startYaw = stage.Yaw;
System.Collections.Generic.List<string> written = new System.Collections.Generic.List<string>();
try
{
@@ -691,7 +707,9 @@ namespace Ashwild.EditorTools
byte[] png = frame.EncodeToPNG();
DestroyImmediate(frame);
File.WriteAllBytes(Path.Combine(folder, $"{baseName}_{i:000}.png"), png);
string framePath = Path.Combine(folder, $"{baseName}_{i:000}.png");
File.WriteAllBytes(framePath, png);
written.Add(framePath);
}
}
catch (Exception e)
@@ -702,10 +720,10 @@ namespace Ashwild.EditorTools
{
EditorUtility.ClearProgressBar();
stage.Yaw = startYaw;
RefreshIfInProject(folder);
foreach (string framePath in written) ImportAsSprite(framePath);
RenderPreview();
Debug.Log($"[ScreenshotStudio] Exported {turntableFrames}-frame turntable → {folder}");
ShowNotification(new GUIContent($"Exported {turntableFrames} frames"));
Debug.Log($"[ScreenshotStudio] Exported {written.Count}-frame turntable → {folder}");
ShowNotification(new GUIContent($"Exported {written.Count} frames"));
}
}
@@ -752,15 +770,40 @@ namespace Ashwild.EditorTools
}
/// <summary>
/// Imports the written file so it appears in the Project window when it lives under Assets.
/// The project-relative "Assets/…" path for a written file, or null when it lives outside the
/// project — so external-folder exports skip the in-project import step entirely.
/// </summary>
private static void RefreshIfInProject(string path)
private static string ToAssetPath(string path)
{
string full = Path.GetFullPath(path).Replace('\\', '/');
string root = Path.GetFullPath(Application.dataPath).Replace('\\', '/');
if (!full.StartsWith(root, StringComparison.OrdinalIgnoreCase)) return;
if (!full.StartsWith(root, StringComparison.OrdinalIgnoreCase)) return null;
AssetDatabase.Refresh();
return "Assets" + full.Substring(root.Length);
}
/// <summary>
/// Imports the written file when it lives under Assets and retypes it as a single-mode Sprite, so
/// studio exports drop straight into UI/inventory work without a manual texture-type change in the
/// inspector. Files exported to an external folder are left untouched.
/// </summary>
private static void ImportAsSprite(string path)
{
string assetPath = ToAssetPath(path);
if (assetPath == null) return;
AssetDatabase.ImportAsset(assetPath, ImportAssetOptions.ForceSynchronousImport);
TextureImporter importer = AssetImporter.GetAtPath(assetPath) as TextureImporter;
if (importer == null)
{
Debug.LogError($"[ScreenshotStudio] No TextureImporter for '{assetPath}' — cannot set Sprite type.");
return;
}
importer.textureType = TextureImporterType.Sprite;
importer.spriteImportMode = SpriteImportMode.Single;
importer.SaveAndReimport();
}
/// <summary>
@@ -1,47 +1,268 @@
using System.Collections.Generic;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
using Ashwild.Network;
namespace Ashwild.EditorTools
{
/// <summary>
/// Editor tool that assigns a unique, stable id (>= 0) to every scene WorldObject in the open
/// scene — Pickables and Harvestables alike, in one shared sequence. The ids are baked into the
/// scene asset, so all clients load the exact same mapping (the scene is shared). Run after
/// scattering or whenever scene objects are added/removed.
/// The one menu item that keeps the baked ids of scene WorldObjects — Pickables and Harvestables
/// alike — correct. The id is the object's identity across the network: every client loads the same
/// scene, so the same id must designate the same object on every machine.
///
/// It does the whole job in one click, in the order the three steps have to run:
/// strip the ids that ended up baked in prefab <i>assets</i>, give an id to every scene object that
/// lacks a valid one, then verify the result and report it. Splitting them into separate menu items
/// only creates ways to run them in the wrong order, or to forget one.
///
/// Assignment is <b>stable and additive</b>: ids already baked into the scene are kept, and only the
/// objects that need one (never assigned, or colliding with another object) are given the next free
/// number. An earlier version renumbered everything from zero on each run, ordered by
/// <c>InstanceID</c> — an order that is not stable between editor sessions — so a single new bush
/// reshuffled every id in the scene and produced an unreviewable diff.
///
/// Ids belong to a scene, never to a prefab asset: an id baked into a prefab is inherited by every
/// instance placed from it, so all of them collide on that one id from the moment they are created —
/// which reads, in game, as objects that play their interaction and give nothing.
/// </summary>
public static class WorldObjectIdAssigner
{
#region Constants
private const string LogPrefix = "[WorldObjectIds]";
/// <summary>
/// How many fixed objects are named in the summary before it collapses into a count — a freshly
/// scattered zone can need hundreds of ids, and an unreadable console helps nobody.
/// </summary>
private const int MaxNamesInSummary = 8;
#endregion
#region Menu
/// <summary>
/// Finds all WorldObjects in the open scene and assigns them sequential unique ids.
/// Clears prefab-baked ids, assigns an id to every scene WorldObject that lacks a valid one, then
/// validates the outcome. Reports what changed and, if anything is still wrong, logs each culprit
/// with its object as context so clicking the message selects it in the hierarchy.
/// </summary>
[MenuItem("Tools/Ashwild/Assign World Object IDs")]
public static void Assign()
[MenuItem("Tools/Ashwild/Setup World Object IDs")]
public static void Setup()
{
WorldObject[] objects = Object.FindObjectsByType<WorldObject>(FindObjectsInactive.Include, FindObjectsSortMode.None);
if (objects.Length == 0)
if (PrefabStageUtility.GetCurrentPrefabStage() != null)
{
Debug.Log("[WorldObjectIdAssigner] No WorldObject found in the open scene.");
Debug.LogError($"{LogPrefix} Ids are per scene, not per prefab. Close the prefab stage and run this " +
"from the scene that contains the objects.");
return;
}
// Deterministic order so re-runs are stable within an editor session.
System.Array.Sort(objects, (a, b) => a.GetInstanceID().CompareTo(b.GetInstanceID()));
int clearedPrefabs = ClearPrefabIds();
int next = 0;
foreach (WorldObject obj in objects)
if (!TryCollect(out List<WorldObject> objects)) return;
List<WorldObject> assigned = AssignMissingIds(objects);
if (assigned.Count > 0) MarkScenesDirty(objects);
Report(objects, clearedPrefabs, assigned);
Validate(objects);
}
#endregion
#region Steps
/// <summary>
/// Strips the id baked into WorldObject prefab <i>assets</i>, so newly placed instances start
/// unassigned instead of all inheriting the same id. Scene instances keep their own id, which is
/// stored as a prefab override and left untouched — but an instance that had no override now reads
/// as unassigned, which is exactly why the assignment step has to run after this one.
/// Returns how many prefabs were cleaned.
/// </summary>
private static int ClearPrefabIds()
{
string[] guids = AssetDatabase.FindAssets("t:Prefab");
int cleared = 0;
foreach (string guid in guids)
{
SerializedObject so = new SerializedObject(obj);
so.FindProperty("id").intValue = next++;
so.ApplyModifiedPropertiesWithoutUndo();
EditorUtility.SetDirty(obj);
GameObject prefab = AssetDatabase.LoadAssetAtPath<GameObject>(AssetDatabase.GUIDToAssetPath(guid));
if (prefab == null) continue;
bool changed = false;
foreach (WorldObject obj in prefab.GetComponentsInChildren<WorldObject>(true))
{
if (obj.Id < 0) continue;
WriteId(obj, -1);
changed = true;
}
if (!changed) continue;
EditorUtility.SetDirty(prefab);
cleared++;
}
EditorSceneManager.MarkSceneDirty(objects[0].gameObject.scene);
Debug.Log($"[WorldObjectIdAssigner] Assigned ids to {objects.Length} WorldObject(s) (pickables + harvestables). Save the scene to bake them.");
if (cleared > 0) AssetDatabase.SaveAssets();
return cleared;
}
/// <summary>
/// Gives the next free id to every object that has none or that duplicates an id already taken,
/// leaving valid ids untouched. Returns the objects that were changed.
/// </summary>
private static List<WorldObject> AssignMissingIds(List<WorldObject> objects)
{
HashSet<int> taken = new HashSet<int>();
List<WorldObject> assigned = new List<WorldObject>();
int next = 0;
foreach (WorldObject obj in objects)
{
if (obj.Id >= 0 && taken.Add(obj.Id)) continue;
while (!taken.Add(next)) next++;
WriteId(obj, next);
assigned.Add(obj);
}
return assigned;
}
/// <summary>
/// Re-checks the ids after the fact and logs every remaining problem against its object. Nothing
/// should ever be reported here — it is the proof that the run actually worked, rather than a
/// summary claiming it did.
/// </summary>
private static void Validate(List<WorldObject> objects)
{
Dictionary<int, WorldObject> byId = new Dictionary<int, WorldObject>();
int problems = 0;
foreach (WorldObject obj in objects)
{
if (obj.Id < 0)
{
problems++;
Debug.LogError($"{LogPrefix} '{GetPath(obj)}' still has no id — it will not be interactable in game.", obj);
continue;
}
if (byId.TryGetValue(obj.Id, out WorldObject other))
{
problems++;
Debug.LogError($"{LogPrefix} '{GetPath(obj)}' still shares id {obj.Id} with '{GetPath(other)}' — " +
"using one silently disables the other.", obj);
continue;
}
byId.Add(obj.Id, obj);
}
if (problems > 0)
Debug.LogError($"{LogPrefix} {problems} id problem(s) survived the setup — this should not happen; " +
"check the errors above.");
}
/// <summary>
/// Logs what the run changed, in one line, and reminds to save when the scene was touched.
/// </summary>
private static void Report(List<WorldObject> objects, int clearedPrefabs, List<WorldObject> assigned)
{
string prefabs = clearedPrefabs > 0 ? $"Cleared the baked id of {clearedPrefabs} prefab(s). " : string.Empty;
if (assigned.Count == 0)
{
Debug.Log($"{LogPrefix} {prefabs}All {objects.Count} scene WorldObject(s) already have a unique id — nothing to assign.");
return;
}
Debug.Log($"{LogPrefix} {prefabs}Assigned an id to {assigned.Count} of {objects.Count} scene WorldObject(s): " +
$"{Describe(assigned)}. Save the scene to bake them.");
}
#endregion
#region Internal Helpers
/// <summary>
/// Gathers the WorldObjects of the open scenes, in a deterministic order (scene, then hierarchy
/// path) so two runs walk them the same way and hand out the same ids.
/// </summary>
private static bool TryCollect(out List<WorldObject> objects)
{
objects = null;
WorldObject[] found = Object.FindObjectsByType<WorldObject>(FindObjectsInactive.Include, FindObjectsSortMode.None);
if (found.Length == 0)
{
Debug.Log($"{LogPrefix} No WorldObject found in the open scene(s).");
return false;
}
objects = new List<WorldObject>(found);
objects.Sort((a, b) =>
{
int byScene = string.CompareOrdinal(a.gameObject.scene.path, b.gameObject.scene.path);
return byScene != 0 ? byScene : string.CompareOrdinal(GetPath(a), GetPath(b));
});
return true;
}
/// <summary>
/// Writes the id through SerializedObject so the change is recorded as a prefab override on an
/// instance, rather than silently editing the shared prefab value.
/// </summary>
private static void WriteId(WorldObject obj, int id)
{
SerializedObject so = new SerializedObject(obj);
so.FindProperty("id").intValue = id;
so.ApplyModifiedPropertiesWithoutUndo();
EditorUtility.SetDirty(obj);
}
/// <summary>
/// Marks every scene that owns one of these objects dirty, since the open scenes may be several
/// (additive loading) and only the touched ones need saving.
/// </summary>
private static void MarkScenesDirty(List<WorldObject> objects)
{
HashSet<Scene> scenes = new HashSet<Scene>();
foreach (WorldObject obj in objects)
scenes.Add(obj.gameObject.scene);
foreach (Scene scene in scenes)
if (scene.IsValid()) EditorSceneManager.MarkSceneDirty(scene);
}
/// <summary>
/// A short, readable summary of the objects that were given an id.
/// </summary>
private static string Describe(List<WorldObject> objects)
{
List<string> names = new List<string>();
for (int i = 0; i < objects.Count && i < MaxNamesInSummary; i++)
names.Add($"{objects[i].name} (id {objects[i].Id})");
if (objects.Count > MaxNamesInSummary) names.Add($"… and {objects.Count - MaxNamesInSummary} more");
return string.Join(", ", names);
}
/// <summary>
/// Hierarchy path of an object, used both for the deterministic ordering and for error messages
/// that must point at one specific object among many identically named ones.
/// </summary>
private static string GetPath(WorldObject obj)
{
string path = obj.name;
Transform current = obj.transform.parent;
while (current != null)
{
path = $"{current.name}/{path}";
current = current.parent;
}
return path;
}
#endregion