349 lines
13 KiB
C#
349 lines
13 KiB
C#
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
|
||
}
|
||
}
|