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

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
}
}