(Feat) Tools Creation
This commit is contained in:
@@ -0,0 +1,463 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEditor;
|
||||
using UnityEditor.UIElements;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
using Ashwild.Crafting;
|
||||
using Ashwild.Inventory;
|
||||
|
||||
namespace Ashwild.EditorTools
|
||||
{
|
||||
/// <summary>
|
||||
/// The Ashwild Database — a UI Toolkit window for browsing, creating and editing the project's
|
||||
/// authored ScriptableObjects (ItemData and CraftingRecipe). The left pane lists every asset of
|
||||
/// the active tab; the right pane edits the selection through a dedicated custom view
|
||||
/// (ItemEditorView / RecipeEditorView), and for items offers one-click generation of the world
|
||||
/// pickup and in-hand prefabs around a chosen source object (see ItemAssetFactory). Pure UI:
|
||||
/// all asset/prefab work is delegated to the factories and views.
|
||||
/// </summary>
|
||||
public class AshwildDatabaseWindow : EditorWindow
|
||||
{
|
||||
#region Types
|
||||
|
||||
/// <summary>
|
||||
/// Which asset family the left list currently shows.
|
||||
/// </summary>
|
||||
private enum Tab { Items, Recipes }
|
||||
|
||||
#endregion
|
||||
|
||||
#region Constants
|
||||
|
||||
private const string UssPath = "Assets/GAME/Script/Editor/Database/AshwildDatabase.uss";
|
||||
|
||||
#endregion
|
||||
|
||||
#region State
|
||||
|
||||
private Tab activeTab = Tab.Items;
|
||||
private string searchFilter = string.Empty;
|
||||
|
||||
private readonly List<Object> sourceItems = new List<Object>();
|
||||
private Object selectedAsset;
|
||||
|
||||
#endregion
|
||||
|
||||
#region UI References
|
||||
|
||||
private ListView listView;
|
||||
private VisualElement rightPane;
|
||||
private Button itemsTab;
|
||||
private Button recipesTab;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Menu
|
||||
|
||||
/// <summary>
|
||||
/// Opens (or focuses) the database window.
|
||||
/// </summary>
|
||||
[MenuItem("Tools/Ashwild/Database")]
|
||||
public static void Open()
|
||||
{
|
||||
AshwildDatabaseWindow window = GetWindow<AshwildDatabaseWindow>();
|
||||
window.titleContent = new GUIContent("Ashwild Database");
|
||||
window.minSize = new Vector2(720, 420);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Window Lifecycle
|
||||
|
||||
/// <summary>
|
||||
/// Builds the full window tree once when the window opens.
|
||||
/// </summary>
|
||||
private void CreateGUI()
|
||||
{
|
||||
VisualElement root = rootVisualElement;
|
||||
root.AddToClassList("ash-root");
|
||||
|
||||
StyleSheet sheet = AssetDatabase.LoadAssetAtPath<StyleSheet>(UssPath);
|
||||
if (sheet != null) root.styleSheets.Add(sheet);
|
||||
|
||||
root.Add(BuildToolbar());
|
||||
|
||||
VisualElement body = new VisualElement();
|
||||
body.AddToClassList("ash-body");
|
||||
body.Add(BuildLeftPane());
|
||||
body.Add(BuildRightPane());
|
||||
root.Add(body);
|
||||
|
||||
RefreshList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rebuilds the list when assets change outside the window (created/deleted/renamed elsewhere).
|
||||
/// </summary>
|
||||
private void OnProjectChange()
|
||||
{
|
||||
if (listView != null) RefreshList();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Toolbar
|
||||
|
||||
/// <summary>
|
||||
/// Builds the top toolbar: the two family tabs, a search field, and the database rebuild action.
|
||||
/// </summary>
|
||||
private VisualElement BuildToolbar()
|
||||
{
|
||||
VisualElement toolbar = new VisualElement();
|
||||
toolbar.AddToClassList("ash-toolbar");
|
||||
|
||||
itemsTab = MakeTab("Items", Tab.Items, "d_Prefab Icon");
|
||||
recipesTab = MakeTab("Recipes", Tab.Recipes, "d_ScriptableObject Icon");
|
||||
toolbar.Add(itemsTab);
|
||||
toolbar.Add(recipesTab);
|
||||
|
||||
VisualElement spacer = new VisualElement();
|
||||
spacer.AddToClassList("ash-toolbar-spacer");
|
||||
toolbar.Add(spacer);
|
||||
|
||||
ToolbarSearchField search = new ToolbarSearchField();
|
||||
search.AddToClassList("ash-search");
|
||||
search.RegisterValueChangedCallback(evt =>
|
||||
{
|
||||
searchFilter = evt.newValue ?? string.Empty;
|
||||
RefreshList();
|
||||
});
|
||||
toolbar.Add(search);
|
||||
|
||||
Button rebuild = new Button(ItemDatabaseBuilder.Rebuild) { text = "Rebuild DB" };
|
||||
rebuild.AddToClassList("ash-btn");
|
||||
rebuild.tooltip = "Re-scan every ItemData and rewrite the network ItemDatabase.";
|
||||
toolbar.Add(rebuild);
|
||||
|
||||
return toolbar;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates one toolbar tab button (icon + label) that switches the active family when clicked.
|
||||
/// The icon is a built-in editor icon resolved by name; it is simply skipped if not found.
|
||||
/// </summary>
|
||||
private Button MakeTab(string label, Tab tab, string iconName)
|
||||
{
|
||||
Button button = new Button(() => SetTab(tab));
|
||||
button.AddToClassList("ash-tab");
|
||||
if (tab == activeTab) button.AddToClassList("ash-tab--active");
|
||||
|
||||
Texture icon = EditorGUIUtility.IconContent(iconName).image;
|
||||
if (icon != null)
|
||||
{
|
||||
Image image = new Image { image = icon, scaleMode = ScaleMode.ScaleToFit };
|
||||
image.AddToClassList("ash-tab__icon");
|
||||
button.Add(image);
|
||||
}
|
||||
|
||||
button.Add(new Label(label));
|
||||
return button;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Switches the active family, repaints the tab highlight, clears the selection and reloads.
|
||||
/// </summary>
|
||||
private void SetTab(Tab tab)
|
||||
{
|
||||
if (tab == activeTab) return;
|
||||
activeTab = tab;
|
||||
|
||||
itemsTab.EnableInClassList("ash-tab--active", tab == Tab.Items);
|
||||
recipesTab.EnableInClassList("ash-tab--active", tab == Tab.Recipes);
|
||||
|
||||
selectedAsset = null;
|
||||
RefreshList();
|
||||
ShowSelection();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Left Pane
|
||||
|
||||
/// <summary>
|
||||
/// Builds the left pane: a styled list of the active family plus a footer "New" button.
|
||||
/// </summary>
|
||||
private VisualElement BuildLeftPane()
|
||||
{
|
||||
VisualElement left = new VisualElement();
|
||||
left.AddToClassList("ash-left");
|
||||
|
||||
listView = new ListView(sourceItems)
|
||||
{
|
||||
fixedItemHeight = 48,
|
||||
selectionType = SelectionType.Single,
|
||||
makeItem = MakeRow,
|
||||
bindItem = BindRow
|
||||
};
|
||||
listView.AddToClassList("ash-list");
|
||||
listView.selectionChanged += OnListSelectionChanged;
|
||||
left.Add(listView);
|
||||
|
||||
VisualElement footer = new VisualElement();
|
||||
footer.AddToClassList("ash-footer");
|
||||
Button newButton = new Button(CreateNewAsset) { text = "+ New" };
|
||||
newButton.AddToClassList("ash-btn");
|
||||
newButton.AddToClassList("ash-btn--primary");
|
||||
footer.Add(newButton);
|
||||
left.Add(footer);
|
||||
|
||||
return left;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the reusable visual for a single list row (icon, name, type tag).
|
||||
/// </summary>
|
||||
private VisualElement MakeRow()
|
||||
{
|
||||
VisualElement row = new VisualElement();
|
||||
row.AddToClassList("ash-row");
|
||||
|
||||
VisualElement icon = new VisualElement { name = "icon" };
|
||||
icon.AddToClassList("ash-row__icon");
|
||||
row.Add(icon);
|
||||
|
||||
Label label = new Label { name = "label" };
|
||||
label.AddToClassList("ash-row__label");
|
||||
row.Add(label);
|
||||
|
||||
Label tag = new Label { name = "tag" };
|
||||
tag.AddToClassList("ash-row__tag");
|
||||
row.Add(tag);
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fills a row visual from the asset at the given index (icon from the item/result sprite).
|
||||
/// </summary>
|
||||
private void BindRow(VisualElement element, int index)
|
||||
{
|
||||
Object asset = sourceItems[index];
|
||||
element.Q<Label>("label").text = AssetDisplayName(asset);
|
||||
|
||||
Label tag = element.Q<Label>("tag");
|
||||
tag.text = AssetTag(asset);
|
||||
tag.style.color = asset is ItemData item
|
||||
? AshwildUI.TypeColor(item.ItemType)
|
||||
: new Color(0.5f, 0.52f, 0.58f);
|
||||
|
||||
VisualElement icon = element.Q<VisualElement>("icon");
|
||||
Sprite sprite = AssetIcon(asset);
|
||||
icon.style.backgroundImage = sprite != null ? new StyleBackground(sprite) : new StyleBackground();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stores the clicked asset and shows it in the right pane.
|
||||
/// </summary>
|
||||
private void OnListSelectionChanged(IEnumerable<object> selection)
|
||||
{
|
||||
selectedAsset = null;
|
||||
foreach (object o in selection)
|
||||
{
|
||||
selectedAsset = o as Object;
|
||||
break;
|
||||
}
|
||||
ShowSelection();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Right Pane
|
||||
|
||||
/// <summary>
|
||||
/// Builds the (initially empty) right pane container the selection editor is rendered into.
|
||||
/// </summary>
|
||||
private VisualElement BuildRightPane()
|
||||
{
|
||||
rightPane = new ScrollView();
|
||||
rightPane.AddToClassList("ash-right");
|
||||
return rightPane;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders the editor for the current selection: a slim action bar plus the custom item or
|
||||
/// recipe view. Shows a placeholder when nothing is selected.
|
||||
/// </summary>
|
||||
private void ShowSelection()
|
||||
{
|
||||
if (rightPane == null) return;
|
||||
rightPane.Clear();
|
||||
|
||||
if (selectedAsset == null)
|
||||
{
|
||||
Label empty = new Label("Select an asset on the left, or create a new one.");
|
||||
empty.AddToClassList("ash-empty");
|
||||
rightPane.Add(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
rightPane.Add(BuildActionBar());
|
||||
|
||||
if (selectedAsset is ItemData item)
|
||||
rightPane.Add(new ItemEditorView(item, RefreshRow, ShowSelection).Root);
|
||||
else if (selectedAsset is CraftingRecipe recipe)
|
||||
rightPane.Add(new RecipeEditorView(recipe, RefreshRow).Root);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the slim action bar above the editor: a "Ping" locator and a delete action, aligned
|
||||
/// to the right (the hero inside each view owns the title).
|
||||
/// </summary>
|
||||
private VisualElement BuildActionBar()
|
||||
{
|
||||
VisualElement bar = new VisualElement();
|
||||
bar.AddToClassList("ash-actionbar");
|
||||
|
||||
VisualElement spacer = new VisualElement();
|
||||
spacer.AddToClassList("ash-toolbar-spacer");
|
||||
bar.Add(spacer);
|
||||
|
||||
Button ping = new Button(() => EditorGUIUtility.PingObject(selectedAsset)) { text = "Ping" };
|
||||
ping.AddToClassList("ash-btn");
|
||||
bar.Add(ping);
|
||||
|
||||
Button delete = new Button(DeleteSelected) { text = "Delete" };
|
||||
delete.AddToClassList("ash-btn");
|
||||
bar.Add(delete);
|
||||
|
||||
return bar;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refreshes just the left-list rows so a renamed/retyped/re-iconed asset updates live without
|
||||
/// rebuilding the whole window or stealing focus from the field being edited.
|
||||
/// </summary>
|
||||
private void RefreshRow() => listView.RefreshItems();
|
||||
|
||||
#endregion
|
||||
|
||||
#region Actions
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new asset of the active family, registers it, reloads the list, and selects it.
|
||||
/// </summary>
|
||||
private void CreateNewAsset()
|
||||
{
|
||||
Object created = activeTab == Tab.Items
|
||||
? ItemAssetFactory.CreateItem("NewItem", ItemType.Material)
|
||||
: (Object)RecipeAssetFactory.CreateRecipe("NewRecipe", null);
|
||||
|
||||
if (created == null) return;
|
||||
|
||||
RefreshList();
|
||||
SelectAsset(created);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the selected asset after confirmation, then reloads (rebuilding the item database
|
||||
/// when an item was removed so network ids stay correct).
|
||||
/// </summary>
|
||||
private void DeleteSelected()
|
||||
{
|
||||
if (selectedAsset == null) return;
|
||||
|
||||
string path = AssetDatabase.GetAssetPath(selectedAsset);
|
||||
if (!EditorUtility.DisplayDialog("Delete asset", $"Delete '{selectedAsset.name}'?\n{path}", "Delete", "Cancel"))
|
||||
return;
|
||||
|
||||
bool wasItem = selectedAsset is ItemData;
|
||||
AssetDatabase.DeleteAsset(path);
|
||||
selectedAsset = null;
|
||||
|
||||
if (wasItem) ItemDatabaseBuilder.Rebuild();
|
||||
RefreshList();
|
||||
ShowSelection();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Data
|
||||
|
||||
/// <summary>
|
||||
/// Reloads the source list for the active family, applying the search filter and an alphabetical
|
||||
/// sort, then refreshes the list view and restores the selection highlight if still present.
|
||||
/// </summary>
|
||||
private void RefreshList()
|
||||
{
|
||||
sourceItems.Clear();
|
||||
|
||||
string typeFilter = activeTab == Tab.Items ? "t:ItemData" : "t:CraftingRecipe";
|
||||
string[] guids = AssetDatabase.FindAssets(typeFilter);
|
||||
foreach (string guid in guids)
|
||||
{
|
||||
string path = AssetDatabase.GUIDToAssetPath(guid);
|
||||
Object asset = AssetDatabase.LoadAssetAtPath<Object>(path);
|
||||
if (asset == null) continue;
|
||||
if (!string.IsNullOrEmpty(searchFilter)
|
||||
&& AssetDisplayName(asset).IndexOf(searchFilter, System.StringComparison.OrdinalIgnoreCase) < 0)
|
||||
continue;
|
||||
sourceItems.Add(asset);
|
||||
}
|
||||
|
||||
sourceItems.Sort((a, b) => string.CompareOrdinal(AssetDisplayName(a), AssetDisplayName(b)));
|
||||
|
||||
listView.itemsSource = sourceItems;
|
||||
listView.RefreshItems();
|
||||
|
||||
int index = selectedAsset != null ? sourceItems.IndexOf(selectedAsset) : -1;
|
||||
if (index >= 0) listView.SetSelectionWithoutNotify(new[] { index });
|
||||
else listView.ClearSelection();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reloads, selects an asset by reference and scrolls it into view.
|
||||
/// </summary>
|
||||
private void SelectAsset(Object asset)
|
||||
{
|
||||
selectedAsset = asset;
|
||||
int index = sourceItems.IndexOf(asset);
|
||||
if (index >= 0)
|
||||
{
|
||||
listView.SetSelection(index);
|
||||
listView.ScrollToItem(index);
|
||||
}
|
||||
ShowSelection();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Asset Display Helpers
|
||||
|
||||
/// <summary>
|
||||
/// The human label for an asset: its authored name when set, otherwise the asset file name.
|
||||
/// </summary>
|
||||
private static string AssetDisplayName(Object asset)
|
||||
{
|
||||
if (asset is ItemData item && !string.IsNullOrWhiteSpace(item.ItemName)) return item.ItemName;
|
||||
if (asset is CraftingRecipe recipe && !string.IsNullOrWhiteSpace(recipe.RecipeName)) return recipe.RecipeName;
|
||||
return asset != null ? asset.name : string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The short type tag shown on the right of a list row (item type, or "Recipe").
|
||||
/// </summary>
|
||||
private static string AssetTag(Object asset)
|
||||
{
|
||||
if (asset is ItemData item) return item.ItemType.ToString();
|
||||
if (asset is CraftingRecipe) return "Recipe";
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The preview sprite for a row: the item icon, or the recipe result's icon.
|
||||
/// </summary>
|
||||
private static Sprite AssetIcon(Object asset)
|
||||
{
|
||||
if (asset is ItemData item) return item.Icon;
|
||||
if (asset is CraftingRecipe recipe) return recipe.Icon;
|
||||
return null;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user