464 lines
16 KiB
C#
464 lines
16 KiB
C#
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
|
|
}
|
|
}
|