Files
Emberwild/Assets/GAME/Script/Editor/Database/RecipeEditorView.cs
T
2026-06-22 23:32:46 +02:00

349 lines
13 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}