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 { /// /// The custom, context-aware editor for an ItemData shown in the right pane of the Ashwild /// Database. Replaces the default ScriptableObject inspector with a hero header (large icon, name, /// type badge) and a set of cards that reveal themselves only when relevant — the Tool card for /// tools/weapons, the Consumable card for consumables, fuel duration only when the item is fuel. /// All fields bind live to the asset, and the bottom card generates the world/hand prefabs by /// wrapping a chosen source object (see ItemAssetFactory). /// public class ItemEditorView { #region State public VisualElement Root { get; } private readonly ItemData item; private readonly SerializedObject so; private readonly Action onMetaChanged; private readonly Action onPrefabGenerated; private const int IconPickerControlId = 0x41534849; private VisualElement iconPreview; private VisualElement badgeHolder; private VisualElement toolCard; private VisualElement consumableCard; private VisualElement cookingCard; private VisualElement fuelCard; private VisualElement maxStackRow; private VisualElement fuelSecondsRow; private VisualElement generationCard; private ObjectField worldField; private ObjectField handField; private IMGUIContainer iconPickerProxy; private ItemType currentType; private GameObject pendingSource; #endregion #region Construction /// /// Builds the full editor tree for an item. is raised when the /// name, icon or type changes so the list can refresh that row; /// after a prefab is built so the view re-renders with the new reference. /// public ItemEditorView(ItemData item, Action onMetaChanged, Action onPrefabGenerated) { this.item = item; this.onMetaChanged = onMetaChanged; this.onPrefabGenerated = onPrefabGenerated; so = new SerializedObject(item); Root = new VisualElement(); Root.Add(BuildHero()); Root.Add(BuildIdentityCard()); Root.Add(BuildStackingCard()); Root.Add(BuildToolCard()); Root.Add(BuildConsumableCard()); Root.Add(BuildCookingCard()); Root.Add(BuildFuelCard()); Root.Add(BuildPrefabsCard()); Root.Add(BuildAnimationCard()); Root.Add(BuildGenerationCard()); currentType = item.ItemType; ApplyTypeVisibility(currentType); SetRowVisible(maxStackRow, item.IsStackable); SetRowVisible(fuelSecondsRow, item.IsFuel); UpdateGenerationVisibility(); } #endregion #region Hero /// /// Builds the hero header: a large icon preview, the editable item name, the type dropdown and /// the coloured type badge. Name/type changes notify the list; the badge and conditional cards /// react to the type immediately. /// private VisualElement BuildHero() { VisualElement hero = new VisualElement(); hero.AddToClassList("ash-hero"); iconPreview = new VisualElement(); iconPreview.AddToClassList("ash-hero__icon"); iconPreview.AddToClassList("ash-hero__icon--clickable"); iconPreview.tooltip = "Click to change the item icon"; iconPreview.RegisterCallback(_ => OpenIconPicker()); UpdateIconPreview(item.Icon); hero.Add(iconPreview); iconPickerProxy = new IMGUIContainer(HandleIconPickerCommands); iconPickerProxy.style.position = Position.Absolute; iconPickerProxy.style.width = 1; iconPickerProxy.style.height = 1; hero.Add(iconPickerProxy); VisualElement info = new VisualElement(); info.AddToClassList("ash-hero__info"); TextField nameField = new TextField { value = item.ItemName }; nameField.AddToClassList("ash-hero__name"); nameField.BindProperty(so.FindProperty("itemName")); nameField.RegisterValueChangedCallback(_ => onMetaChanged?.Invoke()); nameField.RegisterCallback(_ => RenameAssetToItemName()); info.Add(AshwildUI.EditableNameRow(nameField)); VisualElement typeRow = new VisualElement(); typeRow.AddToClassList("ash-hero__typerow"); EnumField typeField = new EnumField(item.ItemType); typeField.AddToClassList("ash-hero__type"); typeField.BindProperty(so.FindProperty("itemType")); typeField.RegisterValueChangedCallback(evt => { currentType = (ItemType)evt.newValue; ApplyTypeVisibility(currentType); RebuildBadge(currentType); UpdateGenerationVisibility(); onMetaChanged?.Invoke(); }); typeRow.Add(typeField); badgeHolder = new VisualElement(); badgeHolder.AddToClassList("ash-hero__badge"); RebuildBadge(item.ItemType); typeRow.Add(badgeHolder); info.Add(typeRow); hero.Add(info); return hero; } /// /// Paints the type badge for the given type, replacing any previous one. /// private void RebuildBadge(ItemType type) { badgeHolder.Clear(); badgeHolder.Add(AshwildUI.Badge(type.ToString(), AshwildUI.TypeColor(type))); } /// /// Shows the item's icon in the hero preview, or a neutral placeholder when none is set. /// private void UpdateIconPreview(Sprite sprite) { iconPreview.style.backgroundImage = sprite != null ? new StyleBackground(sprite) : new StyleBackground(); } /// /// Opens Unity's sprite object picker, seeded with the current icon, so the designer can change /// the item icon by clicking the hero preview directly. /// private void OpenIconPicker() { EditorGUIUtility.ShowObjectPicker(item.Icon, false, string.Empty, IconPickerControlId); } /// /// Listens (through the hidden IMGUI proxy) for the picker selecting/closing on our control id /// and applies the chosen sprite live. ObjectSelectorUpdated fires on each highlight, so the /// hero, the bound Icon field and the list all preview the change immediately. /// private void HandleIconPickerCommands() { Event evt = Event.current; if (evt == null || evt.type != EventType.ExecuteCommand) return; if (EditorGUIUtility.GetObjectPickerControlID() != IconPickerControlId) return; if (evt.commandName != "ObjectSelectorUpdated" && evt.commandName != "ObjectSelectorClosed") return; AssignIcon(EditorGUIUtility.GetObjectPickerObject() as Sprite); } /// /// Writes the picked icon onto the asset (no-op when unchanged) and refreshes the hero preview /// and the list row. /// private void AssignIcon(Sprite sprite) { SerializedProperty iconProp = so.FindProperty("icon"); if (iconProp.objectReferenceValue == sprite) return; iconProp.objectReferenceValue = sprite; so.ApplyModifiedProperties(); UpdateIconPreview(sprite); onMetaChanged?.Invoke(); } #endregion #region Asset Naming /// /// 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). /// 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(); } /// /// 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. /// 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 /// /// Identity card: description and icon. Editing the icon updates the hero preview and the list. /// private VisualElement BuildIdentityCard() { VisualElement card = AshwildUI.Card("Identity"); TextField description = new TextField("Description") { multiline = true }; description.AddToClassList("ash-description"); description.BindProperty(so.FindProperty("description")); card.Add(description); ObjectField icon = new ObjectField("Icon") { objectType = typeof(Sprite), allowSceneObjects = false }; icon.BindProperty(so.FindProperty("icon")); icon.RegisterValueChangedCallback(evt => { UpdateIconPreview(evt.newValue as Sprite); onMetaChanged?.Invoke(); }); card.Add(icon); return card; } /// /// Stacking card: the stackable toggle and a max-stack field that only shows while stackable. /// private VisualElement BuildStackingCard() { VisualElement card = AshwildUI.Card("Stacking"); Toggle stackable = new Toggle("Is Stackable"); stackable.BindProperty(so.FindProperty("isStackable")); stackable.RegisterValueChangedCallback(evt => SetRowVisible(maxStackRow, evt.newValue)); card.Add(stackable); maxStackRow = new IntegerField("Max Stack Size"); ((IntegerField)maxStackRow).BindProperty(so.FindProperty("maxStackSize")); card.Add(maxStackRow); return card; } /// /// Tool card: harvest type and tool power. Only shown for tools and weapons. /// private VisualElement BuildToolCard() { toolCard = AshwildUI.Card("Tool"); EnumField harvest = new EnumField("Harvest Type", item.HarvestType); harvest.BindProperty(so.FindProperty("harvestType")); toolCard.Add(harvest); FloatField power = new FloatField("Tool Power"); power.BindProperty(so.FindProperty("toolPower")); toolCard.Add(power); return toolCard; } /// /// Consumable card: the restore values. Only shown for consumables. /// private VisualElement BuildConsumableCard() { consumableCard = AshwildUI.Card("Consumable"); consumableCard.Add(BoundFloat("Health Restore", "healthRestore")); consumableCard.Add(BoundFloat("Hunger Restore", "hungerRestore")); consumableCard.Add(BoundFloat("Thirst Restore", "thirstRestore")); return consumableCard; } /// /// Cooking card: the cooked result item and its cook time (applies to any item that can cook). /// private VisualElement BuildCookingCard() { cookingCard = AshwildUI.Card("Cooking"); ObjectField cooked = new ObjectField("Cooked Result") { objectType = typeof(ItemData), allowSceneObjects = false }; cooked.BindProperty(so.FindProperty("cookedResult")); cookingCard.Add(cooked); cookingCard.Add(BoundFloat("Cook Time", "cookTime")); return cookingCard; } /// /// Fuel card: the fuel toggle and a burn-duration field revealed only while the item is fuel. /// private VisualElement BuildFuelCard() { fuelCard = AshwildUI.Card("Fuel"); Toggle isFuel = new Toggle("Is Fuel"); isFuel.BindProperty(so.FindProperty("isFuel")); isFuel.RegisterValueChangedCallback(evt => SetRowVisible(fuelSecondsRow, evt.newValue)); fuelCard.Add(isFuel); fuelSecondsRow = BoundFloat("Fuel Seconds", "fuelSeconds"); fuelCard.Add(fuelSecondsRow); return fuelCard; } /// /// Prefabs card: the world and hand prefab references (auto-filled by generation, editable here). /// private VisualElement BuildPrefabsCard() { VisualElement card = AshwildUI.Card("Prefabs"); worldField = new ObjectField("World Prefab") { objectType = typeof(GameObject), allowSceneObjects = false }; worldField.BindProperty(so.FindProperty("worldPrefab")); worldField.RegisterValueChangedCallback(_ => UpdateGenerationVisibility()); card.Add(worldField); handField = new ObjectField("Hand Prefab") { objectType = typeof(GameObject), allowSceneObjects = false }; handField.BindProperty(so.FindProperty("handPrefab")); handField.RegisterValueChangedCallback(_ => UpdateGenerationVisibility()); card.Add(handField); return card; } /// /// 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. /// 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; } /// /// 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. /// private VisualElement BuildGenerationCard() { generationCard = AshwildUI.Card("Prefab Generation"); generationCard.AddToClassList("ash-card--accent"); 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"); 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; } #endregion #region Actions /// /// 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. /// private void GeneratePrefabs() { bool generated = false; 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 #region Conditional Visibility /// /// Reveals only the cards that make sense for the item type: Tool for tools/weapons; Consumable /// for consumables; Cooking for consumables (food) and materials (smelting ore → ingot), but /// never for a tool or weapon; and Fuel for materials only (a consumable is never burned). /// private void ApplyTypeVisibility(ItemType type) { SetRowVisible(toolCard, type == ItemType.Tool || type == ItemType.Weapon); SetRowVisible(consumableCard, type == ItemType.Consumable); SetRowVisible(cookingCard, type == ItemType.Consumable || type == ItemType.Material); SetRowVisible(fuelCard, type == ItemType.Material); } /// /// 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. /// private void UpdateGenerationVisibility() { if (worldField == null || handField == null || generationCard == null) return; bool isToolLike = currentType == ItemType.Tool || currentType == ItemType.Weapon; bool needWorld = worldField.value == null; bool needHand = isToolLike && handField.value == null; SetRowVisible(generationCard, needWorld || needHand); } /// /// Collapses or shows an element without leaving a gap when hidden. /// private static void SetRowVisible(VisualElement element, bool visible) { if (element != null) element.style.display = visible ? DisplayStyle.Flex : DisplayStyle.None; } /// /// Builds a float field already bound to a serialized property. /// private FloatField BoundFloat(string label, string property) { FloatField field = new FloatField(label); field.BindProperty(so.FindProperty(property)); return field; } #endregion } }