using System; using System.IO; using Unity.Netcode; using Unity.Netcode.Components; using UnityEditor; using UnityEngine; public enum ItemCreationDefinitionKind { Generic, Material, Part, Rocket, Usable } public enum ItemCreationMovementKind { Carry, Drag, Hand, Static } public sealed class ItemCreationWindow : EditorWindow { private const string DefaultDefinitionFolder = "Assets/CONTENT/Game/Data/Items"; private const string DefaultPrefabFolder = "Assets/CONTENT/Game/Prefabs/Items"; private const string DefaultCatalogPath = "Assets/CONTENT/Game/Data/Items/ItemCatalog.asset"; private const string DefaultNetworkPrefabsPath = "Assets/DefaultNetworkPrefabs.asset"; [SerializeField] private string assetName = "NewItem"; [SerializeField] private string itemId = "new-item"; [SerializeField] private string displayName = "New Item"; [SerializeField] private ItemDefinition existingDefinition; [SerializeField] private ItemCreationDefinitionKind definitionKind; [SerializeField] private ItemCreationMovementKind movementKind = ItemCreationMovementKind.Carry; [SerializeField] private GameObject existingWorldPrefab; [SerializeField] private GameObject visualPrefab; [SerializeField] private string definitionFolder = DefaultDefinitionFolder; [SerializeField] private string prefabFolder = DefaultPrefabFolder; [SerializeField] private string partTypeId = "part"; [SerializeField] private PartProcessingState partProcessingState; [SerializeField] private string rocketTypeId = "rocket"; [SerializeField] private float criticalImpactSpeed = 7f; [SerializeField] private float dragWeight = 35f; [SerializeField] private MonoScript handItemBehaviour; [SerializeField] private bool addFurnaceMetalSource; [SerializeField] private float furnaceMetalAmount = 25f; [SerializeField] private ItemCatalog catalog; [SerializeField] private NetworkPrefabsList networkPrefabsList; [MenuItem("FORT-BO/Items/Item Creator")] private static void Open() { ItemCreationWindow window = GetWindow("Item Creator"); window.minSize = new Vector2(470f, 650f); } private void OnEnable() { if (catalog == null) catalog = AssetDatabase.LoadAssetAtPath(DefaultCatalogPath); if (networkPrefabsList == null) { networkPrefabsList = AssetDatabase.LoadAssetAtPath(DefaultNetworkPrefabsPath); } } private void OnGUI() { EditorGUILayout.LabelField("Identity", EditorStyles.boldLabel); existingDefinition = (ItemDefinition)EditorGUILayout.ObjectField( "Existing Definition", existingDefinition, typeof(ItemDefinition), false); using (new EditorGUI.DisabledScope(existingDefinition != null)) { assetName = EditorGUILayout.TextField("Asset Name", assetName); itemId = EditorGUILayout.TextField("Item Id", itemId); displayName = EditorGUILayout.TextField("Display Name", displayName); definitionKind = (ItemCreationDefinitionKind)EditorGUILayout.EnumPopup( "Definition Kind", definitionKind); } if (existingDefinition != null) { EditorGUILayout.LabelField( "Definition Kind", GetDefinitionKind(existingDefinition).ToString()); } movementKind = (ItemCreationMovementKind)EditorGUILayout.EnumPopup( "Movement Kind", movementKind); EditorGUILayout.Space(); EditorGUILayout.LabelField("Prefab", EditorStyles.boldLabel); existingWorldPrefab = (GameObject)EditorGUILayout.ObjectField( "Existing World Prefab", existingWorldPrefab, typeof(GameObject), false); bool hasWorldPrefab = ResolveExistingWorldPrefab() != null; using (new EditorGUI.DisabledScope(hasWorldPrefab)) { visualPrefab = (GameObject)EditorGUILayout.ObjectField( "Visual Prefab", visualPrefab, typeof(GameObject), false); prefabFolder = EditorGUILayout.TextField("Prefab Folder", prefabFolder); } EditorGUILayout.HelpBox( hasWorldPrefab ? "The existing prefab is configured in place. Existing components are preserved." : "A new network prefab is created. Visual Prefab is added as a child and may be empty.", MessageType.Info); if (existingDefinition == null) DrawDefinitionSpecificFields(); DrawMovementSpecificFields(); EditorGUILayout.Space(); EditorGUILayout.LabelField("Optional capabilities", EditorStyles.boldLabel); addFurnaceMetalSource = EditorGUILayout.Toggle( "Furnace Metal Source", addFurnaceMetalSource); if (addFurnaceMetalSource) { furnaceMetalAmount = EditorGUILayout.FloatField( "Metal Amount", furnaceMetalAmount); } EditorGUILayout.Space(); EditorGUILayout.LabelField("Registration", EditorStyles.boldLabel); using (new EditorGUI.DisabledScope(existingDefinition != null)) { definitionFolder = EditorGUILayout.TextField( "Definition Folder", definitionFolder); } catalog = (ItemCatalog)EditorGUILayout.ObjectField( "Item Catalog", catalog, typeof(ItemCatalog), false); networkPrefabsList = (NetworkPrefabsList)EditorGUILayout.ObjectField( "Network Prefabs List", networkPrefabsList, typeof(NetworkPrefabsList), false); EditorGUILayout.Space(); EditorGUILayout.HelpBox( "Definition Kind describes what the item is. Movement Kind describes how its prefab " + "is handled. They are intentionally independent.", MessageType.None); using (new EditorGUI.DisabledScope(EditorApplication.isPlayingOrWillChangePlaymode)) { string buttonLabel = existingDefinition == null ? "Create and Register Item" : "Configure and Register Item"; if (GUILayout.Button(buttonLabel, GUILayout.Height(34f))) CreateAndRegisterItem(); } } private void DrawDefinitionSpecificFields() { switch (definitionKind) { case ItemCreationDefinitionKind.Part: EditorGUILayout.Space(); EditorGUILayout.LabelField("Part", EditorStyles.boldLabel); partTypeId = EditorGUILayout.TextField("Part Type Id", partTypeId); partProcessingState = (PartProcessingState)EditorGUILayout.EnumPopup( "Processing State", partProcessingState); break; case ItemCreationDefinitionKind.Rocket: EditorGUILayout.Space(); EditorGUILayout.LabelField("Rocket", EditorStyles.boldLabel); rocketTypeId = EditorGUILayout.TextField("Rocket Type Id", rocketTypeId); criticalImpactSpeed = EditorGUILayout.FloatField( "Critical Impact Speed", criticalImpactSpeed); break; } } private void DrawMovementSpecificFields() { switch (movementKind) { case ItemCreationMovementKind.Drag: EditorGUILayout.Space(); EditorGUILayout.LabelField("Drag", EditorStyles.boldLabel); dragWeight = EditorGUILayout.FloatField("Weight", dragWeight); break; case ItemCreationMovementKind.Hand: EditorGUILayout.Space(); EditorGUILayout.LabelField("Hand", EditorStyles.boldLabel); handItemBehaviour = (MonoScript)EditorGUILayout.ObjectField( "Custom Hand Interactable", handItemBehaviour, typeof(MonoScript), false); EditorGUILayout.HelpBox( "Leave empty to use NetworkUsableHandItemInteractable. Add a separate " + "IHandItemUseAction component for the actual effect.", MessageType.Info); break; } } private void CreateAndRegisterItem() { try { ValidateInput(); ItemDefinition definition; string definitionPath; string safeAssetName; if (existingDefinition != null) { definition = existingDefinition; definitionPath = AssetDatabase.GetAssetPath(definition); safeAssetName = SanitizeFileName(definition.name); } else { EnsureFolder(definitionFolder); safeAssetName = SanitizeFileName(assetName.Trim()); definitionPath = $"{definitionFolder}/{safeAssetName}.asset"; if (AssetDatabase.LoadMainAssetAtPath(definitionPath) != null) { throw new InvalidOperationException( $"Asset already exists: {definitionPath}"); } EnsureUniqueItemId(itemId); definition = CreateDefinition(definitionPath); } GameObject worldPrefab = ConfigureOrCreateWorldPrefab(safeAssetName, definition); SetObjectReference( definition, "worldPrefab", worldPrefab.GetComponent()); RegisterInCatalog(definition); RegisterNetworkPrefab(worldPrefab); AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); Selection.activeObject = definition; EditorGUIUtility.PingObject(worldPrefab); Debug.Log( $"Created '{definition.DisplayName}': {definitionPath} + " + $"{AssetDatabase.GetAssetPath(worldPrefab)}.", definition); } catch (Exception exception) { Debug.LogException(exception); EditorUtility.DisplayDialog("Item creation failed", exception.Message, "OK"); } } private void ValidateInput() { if (existingDefinition == null && string.IsNullOrWhiteSpace(assetName)) throw new InvalidOperationException("Asset Name is required."); if (existingDefinition == null && string.IsNullOrWhiteSpace(itemId)) throw new InvalidOperationException("Item Id is required."); if (existingDefinition == null && (string.IsNullOrWhiteSpace(definitionFolder) || !definitionFolder.StartsWith("Assets", StringComparison.Ordinal))) { throw new InvalidOperationException("Definition Folder must be inside Assets."); } GameObject resolvedWorldPrefab = ResolveExistingWorldPrefab(); if (resolvedWorldPrefab != null && !PrefabUtility.IsPartOfPrefabAsset(resolvedWorldPrefab)) { throw new InvalidOperationException("Existing World Prefab must be a prefab asset."); } if (resolvedWorldPrefab != null) ValidateExistingPrefabMovement(resolvedWorldPrefab); if (ResolveExistingWorldPrefab() == null && (string.IsNullOrWhiteSpace(prefabFolder) || !prefabFolder.StartsWith("Assets", StringComparison.Ordinal))) { throw new InvalidOperationException("Prefab Folder must be inside Assets."); } if (movementKind == ItemCreationMovementKind.Hand) ResolveHandItemBehaviourType(); if (EffectiveDefinitionKind == ItemCreationDefinitionKind.Rocket && movementKind is not ItemCreationMovementKind.Carry and not ItemCreationMovementKind.Drag) { throw new InvalidOperationException( "A rocket assembled by the workstation must use Carry or Drag movement."); } if (ResolveExistingWorldPrefab() == null && visualPrefab != null && (visualPrefab.GetComponentInChildren(true) != null || visualPrefab.GetComponentInChildren(true) != null)) { throw new InvalidOperationException( "Visual Prefab must contain visuals/colliders only, without NetworkObject or Rigidbody."); } if (existingDefinition == null && definitionKind == ItemCreationDefinitionKind.Part && string.IsNullOrWhiteSpace(partTypeId)) { throw new InvalidOperationException("Part Type Id is required for a part."); } if (existingDefinition == null && definitionKind == ItemCreationDefinitionKind.Rocket && string.IsNullOrWhiteSpace(rocketTypeId)) { throw new InvalidOperationException("Rocket Type Id is required for a rocket."); } } private void ValidateExistingPrefabMovement(GameObject prefabAsset) { string prefabPath = AssetDatabase.GetAssetPath(prefabAsset); GameObject root = PrefabUtility.LoadPrefabContents(prefabPath); try { EnsureSingleMovementBehaviour(root); } finally { PrefabUtility.UnloadPrefabContents(root); } } private GameObject ResolveExistingWorldPrefab() { if (existingWorldPrefab != null) return existingWorldPrefab; return existingDefinition != null && existingDefinition.WorldPrefab != null ? existingDefinition.WorldPrefab.gameObject : null; } private ItemCreationDefinitionKind EffectiveDefinitionKind => existingDefinition != null ? GetDefinitionKind(existingDefinition) : definitionKind; private static ItemCreationDefinitionKind GetDefinitionKind(ItemDefinition definition) => definition switch { MaterialItemDefinition => ItemCreationDefinitionKind.Material, PartItemDefinition => ItemCreationDefinitionKind.Part, RocketItemDefinition => ItemCreationDefinitionKind.Rocket, UsableItemDefinition => ItemCreationDefinitionKind.Usable, _ => ItemCreationDefinitionKind.Generic }; private ItemDefinition CreateDefinition(string path) { ItemDefinition definition = definitionKind switch { ItemCreationDefinitionKind.Material => CreateInstance(), ItemCreationDefinitionKind.Part => CreateInstance(), ItemCreationDefinitionKind.Rocket => CreateInstance(), ItemCreationDefinitionKind.Usable => CreateInstance(), _ => CreateInstance() }; definition.name = assetName.Trim(); AssetDatabase.CreateAsset(definition, path); SetString(definition, "itemId", NormalizeId(itemId)); SetString(definition, "displayName", displayName?.Trim()); if (definition is PartItemDefinition) { SetString(definition, "partTypeId", NormalizeId(partTypeId)); SetInt(definition, "processingState", (int)partProcessingState); } else if (definition is RocketItemDefinition) { SetString(definition, "rocketTypeId", NormalizeId(rocketTypeId)); SetFloat(definition, "criticalImpactSpeed", Mathf.Max(0.1f, criticalImpactSpeed)); } return definition; } private GameObject ConfigureOrCreateWorldPrefab( string safeAssetName, ItemDefinition definition) { string prefabPath; GameObject prefabAsset = ResolveExistingWorldPrefab(); if (prefabAsset != null) { prefabPath = AssetDatabase.GetAssetPath(prefabAsset); } else { EnsureFolder(prefabFolder); prefabPath = $"{prefabFolder}/{safeAssetName}.prefab"; if (AssetDatabase.LoadMainAssetAtPath(prefabPath) != null) throw new InvalidOperationException($"Prefab already exists: {prefabPath}"); CreateInitialPrefab(prefabPath, safeAssetName); } GameObject root = PrefabUtility.LoadPrefabContents(prefabPath); try { ConfigurePrefabRoot(root, definition); PrefabUtility.SaveAsPrefabAsset(root, prefabPath); } finally { PrefabUtility.UnloadPrefabContents(root); } return AssetDatabase.LoadAssetAtPath(prefabPath); } private void CreateInitialPrefab(string prefabPath, string safeAssetName) { GameObject root = new(safeAssetName); try { if (visualPrefab != null) { GameObject visual = PrefabUtility.InstantiatePrefab(visualPrefab) as GameObject; if (visual == null) visual = Instantiate(visualPrefab); visual.name = "Visual"; visual.transform.SetParent(root.transform, false); } PrefabUtility.SaveAsPrefabAsset(root, prefabPath); } finally { DestroyImmediate(root); } } private void ConfigurePrefabRoot(GameObject root, ItemDefinition definition) { EnsureSingleMovementBehaviour(root); NetworkObject networkObject = GetOrAdd(root); NetworkTransform networkTransform = GetOrAdd(root); networkTransform.AuthorityMode = NetworkTransform.AuthorityModes.Server; networkTransform.UseUnreliableDeltas = true; NetworkItem networkItem = GetOrAdd(root); SetObjectReference(networkItem, "definition", definition); bool needsPhysics = movementKind != ItemCreationMovementKind.Static || EffectiveDefinitionKind == ItemCreationDefinitionKind.Rocket; Rigidbody body = needsPhysics ? GetOrAdd(root) : root.GetComponent(); if (body != null) { body.interpolation = RigidbodyInterpolation.Interpolate; body.collisionDetectionMode = CollisionDetectionMode.Continuous; } if (needsPhysics) { NetworkRigidbody networkRigidbody = GetOrAdd(root); networkRigidbody.UseRigidBodyForMotion = true; EnsureCollider(root); } switch (movementKind) { case ItemCreationMovementKind.Carry: GetOrAdd(root); if (body != null) body.mass = 1f; break; case ItemCreationMovementKind.Drag: NetworkDraggableInteractable draggable = GetOrAdd(root); float safeWeight = Mathf.Max(0.1f, dragWeight); SetFloat(draggable, "weight", safeWeight); if (body != null) body.mass = safeWeight; break; case ItemCreationMovementKind.Hand: Type handBehaviourType = ResolveHandItemBehaviourType(); if (root.GetComponent(handBehaviourType) == null) root.AddComponent(handBehaviourType); if (body != null) body.mass = 0.5f; break; } if (EffectiveDefinitionKind == ItemCreationDefinitionKind.Rocket) { GetOrAdd(root); GetOrAdd(root); } if (addFurnaceMetalSource) { FurnaceMetalSource source = GetOrAdd(root); SetFloat(source, "metalAmount", Mathf.Max(0.01f, furnaceMetalAmount)); } EditorUtility.SetDirty(networkObject); } private void EnsureSingleMovementBehaviour(GameObject root) { bool hasCarry = root.GetComponent() != null; bool hasDrag = root.GetComponent() != null; bool hasHand = root.GetComponent() != null; int existingCount = (hasCarry ? 1 : 0) + (hasDrag ? 1 : 0) + (hasHand ? 1 : 0); if (existingCount > 1) { throw new InvalidOperationException( "The prefab already contains more than one movement behaviour."); } bool compatible = existingCount == 0 || (movementKind == ItemCreationMovementKind.Carry && hasCarry) || (movementKind == ItemCreationMovementKind.Drag && hasDrag) || (movementKind == ItemCreationMovementKind.Hand && hasHand); if (!compatible) { throw new InvalidOperationException( "The prefab already has a different movement behaviour. Remove or change it manually first."); } if (movementKind == ItemCreationMovementKind.Hand && hasHand) { NetworkHandItemInteractable existingHand = root.GetComponent(); if (existingHand.GetType() != ResolveHandItemBehaviourType()) { throw new InvalidOperationException( $"The prefab already uses {existingHand.GetType().Name}, but another hand " + "behaviour was selected."); } } } private Type ResolveHandItemBehaviourType() { if (handItemBehaviour == null) return typeof(NetworkUsableHandItemInteractable); Type type = handItemBehaviour.GetClass(); if (type == null || type.IsAbstract || !typeof(NetworkHandItemInteractable).IsAssignableFrom(type)) { throw new InvalidOperationException( "Use Behaviour must be a non-abstract NetworkHandItemInteractable script."); } return type; } private void EnsureUniqueItemId(string candidateId) { string normalizedId = NormalizeId(candidateId); foreach (string guid in AssetDatabase.FindAssets("t:ItemDefinition")) { string path = AssetDatabase.GUIDToAssetPath(guid); ItemDefinition definition = AssetDatabase.LoadAssetAtPath(path); if (definition != null && definition.ItemId == normalizedId) { throw new InvalidOperationException( $"Item Id '{normalizedId}' is already used by {path}."); } } } private void RegisterInCatalog(ItemDefinition definition) { if (catalog == null) return; SerializedObject serializedCatalog = new(catalog); SerializedProperty items = serializedCatalog.FindProperty("items"); for (int index = 0; index < items.arraySize; index++) { if (items.GetArrayElementAtIndex(index).objectReferenceValue == definition) return; } int newIndex = items.arraySize; items.InsertArrayElementAtIndex(newIndex); items.GetArrayElementAtIndex(newIndex).objectReferenceValue = definition; serializedCatalog.ApplyModifiedPropertiesWithoutUndo(); EditorUtility.SetDirty(catalog); } private void RegisterNetworkPrefab(GameObject prefab) { if (networkPrefabsList == null || networkPrefabsList.Contains(prefab)) return; networkPrefabsList.Add(new NetworkPrefab { Prefab = prefab }); EditorUtility.SetDirty(networkPrefabsList); } private static T GetOrAdd(GameObject gameObject) where T : Component => gameObject.TryGetComponent(out T component) ? component : gameObject.AddComponent(); private static void EnsureCollider(GameObject root) { if (root.GetComponentInChildren(true) != null) return; BoxCollider collider = root.AddComponent(); if (!TryCalculateLocalRendererBounds(root, out Bounds bounds)) return; collider.center = bounds.center; collider.size = bounds.size; } private static bool TryCalculateLocalRendererBounds(GameObject root, out Bounds bounds) { Renderer[] renderers = root.GetComponentsInChildren(true); if (renderers.Length == 0) { bounds = new Bounds(Vector3.zero, Vector3.one); return false; } bool initialized = false; bounds = default; foreach (Renderer renderer in renderers) { Bounds worldBounds = renderer.bounds; Vector3 center = root.transform.InverseTransformPoint(worldBounds.center); Vector3 extents = worldBounds.extents; for (int x = -1; x <= 1; x += 2) for (int y = -1; y <= 1; y += 2) for (int z = -1; z <= 1; z += 2) { Vector3 worldCorner = worldBounds.center + Vector3.Scale(extents, new Vector3(x, y, z)); Vector3 localCorner = root.transform.InverseTransformPoint(worldCorner); if (!initialized) { bounds = new Bounds(center, Vector3.zero); initialized = true; } bounds.Encapsulate(localCorner); } } return initialized; } private static void EnsureFolder(string folderPath) { string normalized = folderPath.Replace('\\', '/').TrimEnd('/'); if (AssetDatabase.IsValidFolder(normalized)) return; string parent = Path.GetDirectoryName(normalized)?.Replace('\\', '/'); string folderName = Path.GetFileName(normalized); if (string.IsNullOrEmpty(parent) || string.IsNullOrEmpty(folderName)) throw new InvalidOperationException($"Invalid folder path: {folderPath}"); EnsureFolder(parent); AssetDatabase.CreateFolder(parent, folderName); } private static string NormalizeId(string value) => value.Trim().ToLowerInvariant().Replace(' ', '-'); private static string SanitizeFileName(string value) { foreach (char invalidCharacter in Path.GetInvalidFileNameChars()) value = value.Replace(invalidCharacter, '-'); return value; } private static void SetString(UnityEngine.Object target, string propertyName, string value) => Edit(target, propertyName, property => property.stringValue = value); private static void SetFloat(UnityEngine.Object target, string propertyName, float value) => Edit(target, propertyName, property => property.floatValue = value); private static void SetInt(UnityEngine.Object target, string propertyName, int value) => Edit(target, propertyName, property => property.intValue = value); private static void SetObjectReference( UnityEngine.Object target, string propertyName, UnityEngine.Object value) => Edit(target, propertyName, property => property.objectReferenceValue = value); private static void Edit( UnityEngine.Object target, string propertyName, Action edit) { SerializedObject serializedObject = new(target); SerializedProperty property = serializedObject.FindProperty(propertyName); if (property == null) throw new MissingFieldException(target.GetType().Name, propertyName); edit(property); serializedObject.ApplyModifiedPropertiesWithoutUndo(); EditorUtility.SetDirty(target); } }