(Feat) Tools Creation
This commit is contained in:
@@ -0,0 +1,348 @@
|
||||
using System;
|
||||
using UnityEditor;
|
||||
using UnityEditor.UIElements;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
using Ashwild.Crafting;
|
||||
using Ashwild.Inventory;
|
||||
|
||||
namespace Ashwild.EditorTools
|
||||
{
|
||||
/// <summary>
|
||||
/// The custom editor for a CraftingRecipe shown in the right pane of the Ashwild Database. The
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class RecipeEditorView
|
||||
{
|
||||
#region State
|
||||
|
||||
public VisualElement Root { get; }
|
||||
|
||||
private const int RecipePickerControlId = 0x41534852;
|
||||
|
||||
private readonly CraftingRecipe recipe;
|
||||
private readonly SerializedObject so;
|
||||
private readonly SerializedProperty ingredientsProp;
|
||||
private readonly Action onMetaChanged;
|
||||
|
||||
private VisualElement equation;
|
||||
private Action<ItemData> pickHandler;
|
||||
private bool pickCommitsOnCloseOnly;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Construction
|
||||
|
||||
/// <summary>
|
||||
/// Builds the recipe editor tree. <paramref name="onMetaChanged"/> is raised when the recipe
|
||||
/// name or result changes so the left list can refresh that row.
|
||||
/// </summary>
|
||||
public RecipeEditorView(CraftingRecipe recipe, Action onMetaChanged)
|
||||
{
|
||||
this.recipe = recipe;
|
||||
this.onMetaChanged = onMetaChanged;
|
||||
so = new SerializedObject(recipe);
|
||||
ingredientsProp = so.FindProperty("ingredients");
|
||||
|
||||
Root = new VisualElement();
|
||||
Root.Add(BuildNameField());
|
||||
Root.Add(BuildEquation());
|
||||
Root.Add(BuildPickerProxy());
|
||||
|
||||
RefreshEquation();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Header
|
||||
|
||||
/// <summary>
|
||||
/// Builds the recipe-name field shown above the equation, styled as the large editable title
|
||||
/// (same look as an item's hero name field); renaming refreshes the list.
|
||||
/// </summary>
|
||||
private VisualElement BuildNameField()
|
||||
{
|
||||
TextField nameField = new TextField();
|
||||
nameField.AddToClassList("ash-hero__name");
|
||||
nameField.AddToClassList("ash-recipe-name");
|
||||
nameField.BindProperty(so.FindProperty("recipeName"));
|
||||
nameField.RegisterValueChangedCallback(_ => onMetaChanged?.Invoke());
|
||||
return nameField;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Equation
|
||||
|
||||
/// <summary>
|
||||
/// Builds the container for the interactive equation; its cards are filled by RefreshEquation.
|
||||
/// </summary>
|
||||
private VisualElement BuildEquation()
|
||||
{
|
||||
equation = new VisualElement();
|
||||
equation.AddToClassList("ash-equation");
|
||||
return equation;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Repaints the whole equation: one interactive card per ingredient (joined by "+"), the dashed
|
||||
/// add tile, then "=", then the result card.
|
||||
/// </summary>
|
||||
private void RefreshEquation()
|
||||
{
|
||||
equation.Clear();
|
||||
|
||||
int count = ingredientsProp.arraySize;
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
if (i > 0) equation.Add(Operator("+"));
|
||||
equation.Add(BuildIngredientCard(i));
|
||||
}
|
||||
|
||||
if (count > 0) equation.Add(Operator("+"));
|
||||
equation.Add(BuildAddTile());
|
||||
|
||||
equation.Add(Operator("="));
|
||||
equation.Add(BuildResultCard());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a large "+"/"=" operator glyph between cards.
|
||||
/// </summary>
|
||||
private static Label Operator(string glyph)
|
||||
{
|
||||
Label op = new Label(glyph);
|
||||
op.AddToClassList("ash-equation__op");
|
||||
return op;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Cards
|
||||
|
||||
/// <summary>
|
||||
/// Builds the interactive card for the ingredient at <paramref name="index"/>: a clickable icon
|
||||
/// (change item), the name, a −/+ quantity stepper, and a remove button.
|
||||
/// </summary>
|
||||
private VisualElement BuildIngredientCard(int index)
|
||||
{
|
||||
SerializedProperty element = ingredientsProp.GetArrayElementAtIndex(index);
|
||||
ItemData item = element.FindPropertyRelative("item").objectReferenceValue as ItemData;
|
||||
int quantity = element.FindPropertyRelative("quantity").intValue;
|
||||
|
||||
VisualElement card = MakeCard(item, () => OpenItemPicker(item, true, picked => SetIngredientItem(index, picked)));
|
||||
card.Add(Stepper(quantity, delta => AdjustIngredientQuantity(index, delta)));
|
||||
|
||||
Button remove = new Button(() => RemoveIngredient(index)) { text = "✕" };
|
||||
remove.AddToClassList("ash-rchip__remove");
|
||||
card.Add(remove);
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the result card: a clickable icon (change result item) and a −/+ quantity stepper.
|
||||
/// </summary>
|
||||
private VisualElement BuildResultCard()
|
||||
{
|
||||
VisualElement card = MakeCard(recipe.ResultItem, () => OpenItemPicker(recipe.ResultItem, true, SetResultItem));
|
||||
card.AddToClassList("ash-rchip--result");
|
||||
card.Add(Stepper(recipe.ResultQuantity, AdjustResultQuantity));
|
||||
return card;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the dashed "+" tile that appends a new ingredient once an item is picked.
|
||||
/// </summary>
|
||||
private VisualElement BuildAddTile()
|
||||
{
|
||||
VisualElement tile = new VisualElement();
|
||||
tile.AddToClassList("ash-rchip");
|
||||
tile.AddToClassList("ash-rchip--add");
|
||||
tile.tooltip = "Add an ingredient";
|
||||
tile.RegisterCallback<ClickEvent>(_ => OpenItemPicker(null, false, AddIngredient));
|
||||
|
||||
Label plus = new Label("+");
|
||||
plus.AddToClassList("ash-rchip__plus");
|
||||
tile.Add(plus);
|
||||
|
||||
return tile;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the shared card body (icon + name) for an item, with the icon wired to open a picker.
|
||||
/// The icon shows the item's sprite, or a neutral placeholder when unset.
|
||||
/// </summary>
|
||||
private VisualElement MakeCard(ItemData item, Action onIconClicked)
|
||||
{
|
||||
VisualElement card = new VisualElement();
|
||||
card.AddToClassList("ash-rchip");
|
||||
|
||||
VisualElement icon = new VisualElement();
|
||||
icon.AddToClassList("ash-rchip__icon");
|
||||
icon.tooltip = "Click to choose an item";
|
||||
if (item != null && item.Icon != null) icon.style.backgroundImage = new StyleBackground(item.Icon);
|
||||
icon.RegisterCallback<ClickEvent>(_ => onIconClicked());
|
||||
card.Add(icon);
|
||||
|
||||
Label name = new Label(item != null ? item.ItemName : "Choose…");
|
||||
name.AddToClassList("ash-rchip__name");
|
||||
card.Add(name);
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a −/+ quantity stepper showing the current value; <paramref name="onDelta"/> receives
|
||||
/// -1 or +1.
|
||||
/// </summary>
|
||||
private VisualElement Stepper(int quantity, Action<int> onDelta)
|
||||
{
|
||||
VisualElement stepper = new VisualElement();
|
||||
stepper.AddToClassList("ash-rchip__stepper");
|
||||
|
||||
Button minus = new Button(() => onDelta(-1)) { text = "−" };
|
||||
minus.AddToClassList("ash-rchip__step");
|
||||
stepper.Add(minus);
|
||||
|
||||
Label value = new Label(quantity.ToString());
|
||||
value.AddToClassList("ash-rchip__qty");
|
||||
stepper.Add(value);
|
||||
|
||||
Button plus = new Button(() => onDelta(1)) { text = "+" };
|
||||
plus.AddToClassList("ash-rchip__step");
|
||||
stepper.Add(plus);
|
||||
|
||||
return stepper;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Mutations
|
||||
|
||||
/// <summary>
|
||||
/// Sets the item of an existing ingredient and repaints.
|
||||
/// </summary>
|
||||
private void SetIngredientItem(int index, ItemData item)
|
||||
{
|
||||
if (index < 0 || index >= ingredientsProp.arraySize) return;
|
||||
ingredientsProp.GetArrayElementAtIndex(index).FindPropertyRelative("item").objectReferenceValue = item;
|
||||
so.ApplyModifiedProperties();
|
||||
RefreshEquation();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Changes an ingredient's quantity by a delta, clamped to a minimum of one, and repaints.
|
||||
/// </summary>
|
||||
private void AdjustIngredientQuantity(int index, int delta)
|
||||
{
|
||||
if (index < 0 || index >= ingredientsProp.arraySize) return;
|
||||
SerializedProperty quantity = ingredientsProp.GetArrayElementAtIndex(index).FindPropertyRelative("quantity");
|
||||
quantity.intValue = Mathf.Max(1, quantity.intValue + delta);
|
||||
so.ApplyModifiedProperties();
|
||||
RefreshEquation();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Appends a new ingredient (quantity 1) for the picked item and repaints; ignores a null pick
|
||||
/// (e.g. the picker was cancelled).
|
||||
/// </summary>
|
||||
private void AddIngredient(ItemData item)
|
||||
{
|
||||
if (item == null) return;
|
||||
|
||||
int index = ingredientsProp.arraySize;
|
||||
ingredientsProp.arraySize++;
|
||||
SerializedProperty element = ingredientsProp.GetArrayElementAtIndex(index);
|
||||
element.FindPropertyRelative("item").objectReferenceValue = item;
|
||||
element.FindPropertyRelative("quantity").intValue = 1;
|
||||
so.ApplyModifiedProperties();
|
||||
RefreshEquation();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes the ingredient at the given index and repaints.
|
||||
/// </summary>
|
||||
private void RemoveIngredient(int index)
|
||||
{
|
||||
if (index < 0 || index >= ingredientsProp.arraySize) return;
|
||||
ingredientsProp.DeleteArrayElementAtIndex(index);
|
||||
so.ApplyModifiedProperties();
|
||||
RefreshEquation();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the recipe's result item, repaints, and refreshes the list row (icon/name follow it).
|
||||
/// </summary>
|
||||
private void SetResultItem(ItemData item)
|
||||
{
|
||||
so.FindProperty("resultItem").objectReferenceValue = item;
|
||||
so.ApplyModifiedProperties();
|
||||
RefreshEquation();
|
||||
onMetaChanged?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Changes the result quantity by a delta, clamped to a minimum of one, and repaints.
|
||||
/// </summary>
|
||||
private void AdjustResultQuantity(int delta)
|
||||
{
|
||||
SerializedProperty quantity = so.FindProperty("resultQuantity");
|
||||
quantity.intValue = Mathf.Max(1, quantity.intValue + delta);
|
||||
so.ApplyModifiedProperties();
|
||||
RefreshEquation();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Item Picker
|
||||
|
||||
/// <summary>
|
||||
/// Builds the hidden IMGUI proxy that relays Unity's object-picker commands to the active pick
|
||||
/// handler (the editor window is UI Toolkit, which can't receive those commands directly).
|
||||
/// </summary>
|
||||
private VisualElement BuildPickerProxy()
|
||||
{
|
||||
IMGUIContainer proxy = new IMGUIContainer(HandlePickerCommands);
|
||||
proxy.style.position = Position.Absolute;
|
||||
proxy.style.width = 1;
|
||||
proxy.style.height = 1;
|
||||
return proxy;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens Unity's item picker seeded with the current item. When <paramref name="commitLive"/>
|
||||
/// is true the pick applies on every highlight (live preview, e.g. changing an existing item);
|
||||
/// otherwise it applies only when the picker closes (e.g. adding a new ingredient — commit once).
|
||||
/// </summary>
|
||||
private void OpenItemPicker(ItemData seed, bool commitLive, Action<ItemData> onPicked)
|
||||
{
|
||||
pickHandler = onPicked;
|
||||
pickCommitsOnCloseOnly = !commitLive;
|
||||
EditorGUIUtility.ShowObjectPicker<ItemData>(seed, false, string.Empty, RecipePickerControlId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forwards the picker's selection to the active handler, honouring the live-vs-on-close policy.
|
||||
/// </summary>
|
||||
private void HandlePickerCommands()
|
||||
{
|
||||
Event evt = Event.current;
|
||||
if (evt == null || evt.type != EventType.ExecuteCommand) return;
|
||||
if (EditorGUIUtility.GetObjectPickerControlID() != RecipePickerControlId) return;
|
||||
|
||||
bool updated = evt.commandName == "ObjectSelectorUpdated";
|
||||
bool closed = evt.commandName == "ObjectSelectorClosed";
|
||||
if (!updated && !closed) return;
|
||||
if (updated && pickCommitsOnCloseOnly) return;
|
||||
|
||||
pickHandler?.Invoke(EditorGUIUtility.GetObjectPickerObject() as ItemData);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user