Files
Emberwild/Assets/GAME/Script/Editor/Database/ItemAssetFactory.cs
Mathew 78bfdf2828 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
2026-07-25 19:42:26 +02:00

253 lines
11 KiB
C#

using System.IO;
using UnityEditor;
using UnityEngine;
using Ashwild.Inventory;
using Ashwild.Network;
using Ashwild.Player;
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) 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
{
#region Constants
private const string ItemsFolder = "Assets/GAME/ScriptableObjects/Items";
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";
private const float FallbackColliderSize = 0.3f;
#endregion
#region Item Asset
/// <summary>
/// Creates a fresh ItemData asset under the items folder with a unique name, seeds its
/// display name and type, registers it in the network ItemDatabase, and returns it so the
/// caller can select and edit it immediately. Returns null only if the folder cannot be made.
/// </summary>
public static ItemData CreateItem(string desiredName, ItemType itemType)
{
if (!EnsureFolder(ItemsFolder)) return null;
string safeName = string.IsNullOrWhiteSpace(desiredName) ? "NewItem" : desiredName.Trim();
string assetPath = AssetDatabase.GenerateUniqueAssetPath($"{ItemsFolder}/{safeName}.asset");
ItemData item = ScriptableObject.CreateInstance<ItemData>();
AssetDatabase.CreateAsset(item, assetPath);
SerializedObject so = new SerializedObject(item);
so.FindProperty("itemName").stringValue = safeName;
so.FindProperty("itemType").enumValueIndex = (int)itemType;
so.ApplyModifiedPropertiesWithoutUndo();
EditorUtility.SetDirty(item);
AssetDatabase.SaveAssets();
ItemDatabaseBuilder.Rebuild();
return item;
}
#endregion
#region World Prefab
/// <summary>
/// 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;
string prefabName = item.name + WorldPrefabSuffix;
GameObject root = InstantiateSourceRoot(source, ResolveLayer(PickableLayerName), prefabName);
FitBoxCollider(root);
Pickable pickable = root.AddComponent<Pickable>();
SerializedObject so = new SerializedObject(pickable);
so.FindProperty("itemData").objectReferenceValue = item;
so.ApplyModifiedPropertiesWithoutUndo();
return SaveAndAssign(root, WorldPrefabFolder, item, "worldPrefab", prefabName);
}
#endregion
#region Hand Prefab
/// <summary>
/// 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;
string prefabName = item.name + HandPrefabSuffix;
GameObject root = InstantiateSourceRoot(source, ResolveLayer(ToolsLayerName), prefabName);
ToolBehaviour tool = root.AddComponent<ToolBehaviour>();
SerializedObject so = new SerializedObject(tool);
so.FindProperty("harvestMask").intValue = 1 << ResolveLayer(HarvestableLayerName);
so.ApplyModifiedPropertiesWithoutUndo();
root.AddComponent<HeldItemOffset>();
return SaveAndAssign(root, HandPrefabFolder, item, "handPrefab", prefabName);
}
#endregion
#region Internal Helpers
/// <summary>
/// Guards the shared preconditions for prefab generation, logging a clear reason on failure.
/// </summary>
private static bool ValidateInputs(ItemData item, GameObject source, string kind)
{
if (item == null)
{
Debug.LogError($"[ItemAssetFactory] Cannot build {kind} prefab — no ItemData selected.");
return false;
}
if (source == null)
{
Debug.LogError($"[ItemAssetFactory] Cannot build {kind} prefab for '{item.name}' — no source object chosen.", item);
return false;
}
return true;
}
/// <summary>
/// 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 InstantiateSourceRoot(GameObject source, int layer, string rootName)
{
GameObject root = PrefabUtility.InstantiatePrefab(source) as GameObject;
if (root == null) root = Object.Instantiate(source);
root.name = rootName;
root.layer = layer;
root.transform.position = Vector3.zero;
root.transform.rotation = Quaternion.identity;
return root;
}
/// <summary>
/// Adds a non-trigger BoxCollider to the root sized to the combined bounds of the source's
/// renderers (in root-local space), falling back to a small default cube when the source has
/// no renderers so the pickup is always interactable.
/// </summary>
private static void FitBoxCollider(GameObject root)
{
BoxCollider box = root.AddComponent<BoxCollider>();
Renderer[] renderers = root.GetComponentsInChildren<Renderer>();
if (renderers.Length == 0)
{
box.size = Vector3.one * FallbackColliderSize;
return;
}
Bounds bounds = renderers[0].bounds;
for (int i = 1; i < renderers.Length; i++) bounds.Encapsulate(renderers[i].bounds);
box.center = root.transform.InverseTransformPoint(bounds.center);
box.size = bounds.size;
}
/// <summary>
/// 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, string prefabName)
{
string prefabPath = AssetDatabase.GenerateUniqueAssetPath($"{folder}/{prefabName}.prefab");
GameObject prefab = PrefabUtility.SaveAsPrefabAsset(root, prefabPath);
Object.DestroyImmediate(root);
if (prefab == null)
{
Debug.LogError($"[ItemAssetFactory] Failed to save prefab at '{prefabPath}'.", item);
return null;
}
SerializedObject so = new SerializedObject(item);
so.FindProperty(itemField).objectReferenceValue = prefab;
so.ApplyModifiedPropertiesWithoutUndo();
EditorUtility.SetDirty(item);
AssetDatabase.SaveAssets();
Debug.Log($"[ItemAssetFactory] Built '{prefabPath}' and assigned it to {item.name}.{itemField}.", prefab);
return 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($"[ItemAssetFactory] Layer '{layerName}' not found — using Default. Add it in the Tags & Layers settings.");
return 0;
}
/// <summary>
/// Ensures a project-relative asset folder exists, creating any missing segments. Returns
/// false (and logs) when the path cannot be created.
/// </summary>
private static bool EnsureFolder(string folder)
{
if (AssetDatabase.IsValidFolder(folder)) return true;
string parent = Path.GetDirectoryName(folder).Replace('\\', '/');
string leaf = Path.GetFileName(folder);
if (!EnsureFolder(parent))
{
Debug.LogError($"[ItemAssetFactory] Could not create folder '{folder}'.");
return false;
}
AssetDatabase.CreateFolder(parent, leaf);
return AssetDatabase.IsValidFolder(folder);
}
#endregion
}
}