Files
Emberwild/Assets/GAME/Script/Editor/Database/ItemEditorView.cs
T
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

504 lines
20 KiB
C#

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
{
/// <summary>
/// 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).
/// </summary>
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
/// <summary>
/// Builds the full editor tree for an item. <paramref name="onMetaChanged"/> is raised when the
/// name, icon or type changes so the list can refresh that row; <paramref name="onPrefabGenerated"/>
/// after a prefab is built so the view re-renders with the new reference.
/// </summary>
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
/// <summary>
/// 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.
/// </summary>
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<ClickEvent>(_ => 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<FocusOutEvent>(_ => 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;
}
/// <summary>
/// Paints the type badge for the given type, replacing any previous one.
/// </summary>
private void RebuildBadge(ItemType type)
{
badgeHolder.Clear();
badgeHolder.Add(AshwildUI.Badge(type.ToString(), AshwildUI.TypeColor(type)));
}
/// <summary>
/// Shows the item's icon in the hero preview, or a neutral placeholder when none is set.
/// </summary>
private void UpdateIconPreview(Sprite sprite)
{
iconPreview.style.backgroundImage = sprite != null ? new StyleBackground(sprite) : new StyleBackground();
}
/// <summary>
/// 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.
/// </summary>
private void OpenIconPicker()
{
EditorGUIUtility.ShowObjectPicker<Sprite>(item.Icon, false, string.Empty, IconPickerControlId);
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// Writes the picked icon onto the asset (no-op when unchanged) and refreshes the hero preview
/// and the list row.
/// </summary>
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
/// <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>
/// Identity card: description and icon. Editing the icon updates the hero preview and the list.
/// </summary>
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;
}
/// <summary>
/// Stacking card: the stackable toggle and a max-stack field that only shows while stackable.
/// </summary>
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;
}
/// <summary>
/// Tool card: harvest type and tool power. Only shown for tools and weapons.
/// </summary>
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;
}
/// <summary>
/// Consumable card: the restore values. Only shown for consumables.
/// </summary>
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;
}
/// <summary>
/// Cooking card: the cooked result item and its cook time (applies to any item that can cook).
/// </summary>
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;
}
/// <summary>
/// Fuel card: the fuel toggle and a burn-duration field revealed only while the item is fuel.
/// </summary>
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;
}
/// <summary>
/// Prefabs card: the world and hand prefab references (auto-filled by generation, editable here).
/// </summary>
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;
}
/// <summary>
/// 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") { 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
/// <summary>
/// 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 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
/// <summary>
/// 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).
/// </summary>
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);
}
/// <summary>
/// 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 needWorld = worldField.value == null;
bool needHand = isToolLike && handField.value == null;
SetRowVisible(generationCard, needWorld || needHand);
}
/// <summary>
/// Collapses or shows an element without leaving a gap when hidden.
/// </summary>
private static void SetRowVisible(VisualElement element, bool visible)
{
if (element != null) element.style.display = visible ? DisplayStyle.Flex : DisplayStyle.None;
}
/// <summary>
/// Builds a float field already bound to a serialized property.
/// </summary>
private FloatField BoundFloat(string label, string property)
{
FloatField field = new FloatField(label);
field.BindProperty(so.FindProperty(property));
return field;
}
#endregion
}
}