using System; using System.Collections.Generic; using UnityEditor; using UnityEngine; using UnityEngine.InputSystem; [CustomEditor(typeof(InputGlyphDatabase))] public sealed class InputGlyphDatabaseEditor : Editor { private const string TablesPropertyName = "tables"; private const string PlaceholderSpritePropertyName = "placeholderSprite"; private const string DeviceNamePropertyName = "deviceName"; private const string SpriteSheetPropertyName = "spriteSheetTexture"; private const string PlatformIconPropertyName = "platformIcons"; private const string EntriesPropertyName = "entries"; private const string EntrySpritePropertyName = "Sprite"; private const string EntryActionPropertyName = "action"; private const float PreviewSize = 52f; private const float ListPreviewSize = 56f; private const int MaxValidationIssuesToShow = 10; private const int DefaultEntriesPerPage = 10; private static readonly string[] DefaultTableNames = { "Keyboard", "Xbox", "PlayStation", "Other" }; private static readonly int[] EntriesPerPageOptions = { 10, 15, 20, 25 }; private static readonly string[] EntriesPerPageLabels = { "10 / 页", "15 / 页", "20 / 页", "25 / 页" }; private sealed class TableEditorState { public Sprite PendingSprite; public bool ShowValidation = true; public string EntrySearch = string.Empty; public int CurrentPage; public int EntriesPerPage = DefaultEntriesPerPage; public readonly List FilteredEntryIndices = new(); public string CachedSearch = string.Empty; public int CachedEntryCount = -1; } private readonly List _tableStates = new(); private InputGlyphDatabase _database; private SerializedProperty _tablesProperty; private SerializedProperty _placeholderSpriteProperty; private int _selectedTab; private bool _showAddTable; private bool _showDatabaseValidation = true; private string _newTableName = string.Empty; private bool IsSettingsSelected => _selectedTab >= TableCount; private int TableCount => _database != null && _database.tables != null ? _database.tables.Count : (_tablesProperty != null ? _tablesProperty.arraySize : 0); private void OnEnable() { _database = target as InputGlyphDatabase; _tablesProperty = serializedObject.FindProperty(TablesPropertyName); _placeholderSpriteProperty = serializedObject.FindProperty(PlaceholderSpritePropertyName); SyncTableStates(); ClampSelectedTab(); } public override void OnInspectorGUI() { if (_database == null || _tablesProperty == null) { DrawDefaultInspector(); return; } serializedObject.Update(); SyncTableStates(); ClampSelectedTab(); DrawToolbar(); if (_showAddTable) { DrawAddTableBar(); } DrawMissingDefaultTablesNotice(); EditorGUILayout.Space(6f); DrawTabs(); EditorGUILayout.Space(8f); using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox)) { if (IsSettingsSelected) { DrawSettingsPanel(); } else { DrawTablePanel(_selectedTab); } } if (serializedObject.ApplyModifiedProperties()) { InvalidateAllEntryViews(); NotifyDatabaseChanged(); } } private void DrawToolbar() { using (new EditorGUILayout.HorizontalScope(EditorStyles.toolbar)) { if (GUILayout.Button("Save Asset", EditorStyles.toolbarButton, GUILayout.Width(90f))) { serializedObject.ApplyModifiedProperties(); InvalidateAllEntryViews(); NotifyDatabaseChanged(true); } if (HasMissingDefaultTables() && GUILayout.Button("Create Standard Tables", EditorStyles.toolbarButton, GUILayout.Width(140f))) { ApplyPendingInspectorChanges(); CreateMissingDefaultTables(); } GUILayout.FlexibleSpace(); GUILayout.Label($"Tables: {TableCount}", EditorStyles.miniLabel); if (GUILayout.Button(_showAddTable ? "Cancel Add" : "+ Add Table", EditorStyles.toolbarButton, GUILayout.Width(90f))) { _showAddTable = !_showAddTable; _newTableName = string.Empty; GUI.FocusControl(null); } } } private void DrawAddTableBar() { using (new EditorGUILayout.HorizontalScope(EditorStyles.toolbar)) { GUILayout.Label("Name", GUILayout.Width(40f)); _newTableName = EditorGUILayout.TextField(_newTableName); using (new EditorGUI.DisabledScope(string.IsNullOrWhiteSpace(_newTableName))) { if (GUILayout.Button("Add", EditorStyles.toolbarButton, GUILayout.Width(70f))) { string trimmed = _newTableName.Trim(); if (HasTable(trimmed)) { EditorUtility.DisplayDialog("Duplicate Table", $"A table named '{trimmed}' already exists.", "OK"); } else { ApplyPendingInspectorChanges(); AddTable(trimmed); _selectedTab = TableCount - 1; _showAddTable = false; _newTableName = string.Empty; GUI.FocusControl(null); } } } } } private void DrawMissingDefaultTablesNotice() { List missingTables = GetMissingDefaultTables(); if (missingTables.Count == 0) { return; } EditorGUILayout.HelpBox( $"Recommended tables are missing: {string.Join(", ", missingTables)}. Glyph lookup still works, but missing categories may fall back to another table.", MessageType.Info); } private void DrawTabs() { using (new EditorGUILayout.HorizontalScope(EditorStyles.toolbar)) { for (int i = 0; i < TableCount; i++) { SerializedProperty tableProperty = _tablesProperty.GetArrayElementAtIndex(i); string tableName = GetTableName(tableProperty, i); bool selected = !IsSettingsSelected && _selectedTab == i; if (GUILayout.Toggle(selected, tableName, EditorStyles.toolbarButton, GUILayout.MinWidth(70f))) { _selectedTab = i; } if (GUILayout.Button("X", EditorStyles.toolbarButton, GUILayout.Width(22f))) { if (EditorUtility.DisplayDialog("Delete Table", $"Delete table '{tableName}' and all of its entries?", "Delete", "Cancel")) { ApplyPendingInspectorChanges(); RemoveTable(i); GUIUtility.ExitGUI(); } } } GUILayout.FlexibleSpace(); bool settingsSelected = IsSettingsSelected; if (GUILayout.Toggle(settingsSelected, "Settings", EditorStyles.toolbarButton, GUILayout.Width(90f))) { _selectedTab = TableCount; } } } private void DrawSettingsPanel() { EditorGUILayout.LabelField("Database Settings", EditorStyles.boldLabel); EditorGUILayout.Space(4f); EditorGUILayout.PropertyField(_placeholderSpriteProperty, new GUIContent("Placeholder Sprite")); DrawSpritePreview(_placeholderSpriteProperty.objectReferenceValue as Sprite, "Preview"); EditorGUILayout.Space(8f); DrawDatabaseValidationPanel(); } private void DrawDatabaseValidationPanel() { List issues = CollectDatabaseValidationIssues(); string title = issues.Count == 0 ? "数据库校验" : $"数据库校验 ({issues.Count})"; _showDatabaseValidation = EditorGUILayout.BeginFoldoutHeaderGroup(_showDatabaseValidation, title); if (_showDatabaseValidation) { if (issues.Count == 0) { EditorGUILayout.HelpBox("未发现数据库级别的问题。", MessageType.Info); } else { DrawValidationList(issues, MessageType.Warning); } } EditorGUILayout.EndFoldoutHeaderGroup(); } private void DrawTablePanel(int tableIndex) { if (tableIndex < 0 || tableIndex >= TableCount) { EditorGUILayout.HelpBox("Select a valid table.", MessageType.Info); return; } TableEditorState state = _tableStates[tableIndex]; SerializedProperty tableProperty = _tablesProperty.GetArrayElementAtIndex(tableIndex); SerializedProperty nameProperty = tableProperty.FindPropertyRelative(DeviceNamePropertyName); SerializedProperty spriteSheetProperty = tableProperty.FindPropertyRelative(SpriteSheetPropertyName); SerializedProperty platformIconProperty = tableProperty.FindPropertyRelative(PlatformIconPropertyName); SerializedProperty entriesProperty = tableProperty.FindPropertyRelative(EntriesPropertyName); EditorGUILayout.LabelField("Table", EditorStyles.boldLabel); EditorGUILayout.PropertyField(nameProperty, new GUIContent("Device Name")); if (HasDuplicateTableName(nameProperty.stringValue, tableIndex)) { EditorGUILayout.HelpBox("Table names should be unique. Duplicate names can make lookups unpredictable.", MessageType.Warning); } EditorGUILayout.Space(6f); DrawInlinePropertyWithPreview(EditorGUILayout.GetControlRect(false, PreviewSize), new GUIContent("Platform Icon"), platformIconProperty, platformIconProperty.objectReferenceValue as Sprite); EditorGUILayout.Space(6f); using (new EditorGUILayout.HorizontalScope()) { EditorGUILayout.PropertyField(spriteSheetProperty, new GUIContent("Sprite Sheet"), true); if (GUILayout.Button("Merge Sprite Sheet")) { ApplyPendingInspectorChanges(); MergeSpriteSheet(tableIndex); } using (new EditorGUI.DisabledScope(_database.tables[tableIndex].entries == null || _database.tables[tableIndex].entries.Count == 0)) { if (GUILayout.Button("Clear Entries")) { if (EditorUtility.DisplayDialog("Clear Entries", $"Remove all entries from '{nameProperty.stringValue}'?", "Clear", "Cancel")) { ApplyPendingInspectorChanges(); ClearEntries(tableIndex); } } } } EditorGUILayout.Space(6f); DrawQuickAddEntry(tableIndex, state); EditorGUILayout.Space(8f); DrawTableValidationPanel(tableIndex, state); EditorGUILayout.Space(8f); DrawEntriesList(tableIndex, entriesProperty); } private void DrawQuickAddEntry(int tableIndex, TableEditorState state) { using (new EditorGUILayout.HorizontalScope(EditorStyles.helpBox)) { state.PendingSprite = (Sprite)EditorGUILayout.ObjectField("Sprite", state.PendingSprite, typeof(Sprite), false); using (new EditorGUI.DisabledScope(state.PendingSprite == null)) { if (GUILayout.Button("Add Entry", GUILayout.Width(90f))) { ApplyPendingInspectorChanges(); AddEntry(tableIndex, state.PendingSprite); state.PendingSprite = null; } } } } private void DrawTableValidationPanel(int tableIndex, TableEditorState state) { List issues = CollectTableValidationIssues(tableIndex); string title = issues.Count == 0 ? "校验结果" : $"校验结果 ({issues.Count})"; state.ShowValidation = EditorGUILayout.BeginFoldoutHeaderGroup(state.ShowValidation, title); if (state.ShowValidation) { if (issues.Count == 0) { EditorGUILayout.HelpBox("未发现当前表的问题。", MessageType.Info); } else { DrawValidationList(issues, MessageType.Warning); } } EditorGUILayout.EndFoldoutHeaderGroup(); } private void DrawValidationList(List issues, MessageType messageType) { int visibleCount = Mathf.Min(MaxValidationIssuesToShow, issues.Count); for (int i = 0; i < visibleCount; i++) { EditorGUILayout.HelpBox(issues[i], messageType); } if (issues.Count > visibleCount) { EditorGUILayout.HelpBox($"还有 {issues.Count - visibleCount} 条问题未展开显示,以保持检视面板可读。", MessageType.None); } } private void DrawEntriesList(int tableIndex, SerializedProperty entriesProperty) { TableEditorState state = _tableStates[tableIndex]; EditorGUILayout.LabelField("Entries", EditorStyles.boldLabel); DrawEntriesControls(state, entriesProperty.arraySize); if (entriesProperty.arraySize == 0) { EditorGUILayout.HelpBox("当前表里还没有任何条目。", MessageType.Info); return; } List filteredIndices = GetFilteredEntryIndices(tableIndex, entriesProperty, state); if (filteredIndices.Count == 0) { EditorGUILayout.HelpBox("没有匹配搜索条件的条目。", MessageType.Info); return; } int entriesPerPage = Mathf.Max(1, state.EntriesPerPage); int totalPages = Mathf.Max(1, Mathf.CeilToInt(filteredIndices.Count / (float)entriesPerPage)); state.CurrentPage = Mathf.Clamp(state.CurrentPage, 0, totalPages - 1); DrawEntriesPagination(state, filteredIndices.Count, entriesProperty.arraySize); EditorGUILayout.Space(4f); int startIndex = state.CurrentPage * entriesPerPage; int endIndex = Mathf.Min(startIndex + entriesPerPage, filteredIndices.Count); for (int i = startIndex; i < endIndex; i++) { int entryIndex = filteredIndices[i]; DrawEntryElement(tableIndex, entryIndex, entriesProperty.GetArrayElementAtIndex(entryIndex)); EditorGUILayout.Space(4f); } if (totalPages > 1) { DrawEntriesPagination(state, filteredIndices.Count, entriesProperty.arraySize); } } private void DrawEntriesControls(TableEditorState state, int totalEntries) { string search = EditorGUILayout.TextField("Search", state.EntrySearch); if (!string.Equals(search, state.EntrySearch, StringComparison.Ordinal)) { state.EntrySearch = search; state.CurrentPage = 0; InvalidateEntryView(state); } using (new EditorGUILayout.HorizontalScope()) { int entriesPerPage = EditorGUILayout.IntPopup("Page Size", state.EntriesPerPage, EntriesPerPageLabels, EntriesPerPageOptions); if (entriesPerPage != state.EntriesPerPage) { state.EntriesPerPage = entriesPerPage; state.CurrentPage = 0; } GUILayout.FlexibleSpace(); EditorGUILayout.LabelField($"总数: {totalEntries}", EditorStyles.miniLabel, GUILayout.Width(80f)); using (new EditorGUI.DisabledScope(string.IsNullOrEmpty(state.EntrySearch))) { if (GUILayout.Button("Clear Search", GUILayout.Width(100f))) { state.EntrySearch = string.Empty; state.CurrentPage = 0; InvalidateEntryView(state); GUI.FocusControl(null); } } } } private void DrawEntriesPagination(TableEditorState state, int filteredCount, int totalEntries) { int entriesPerPage = Mathf.Max(1, state.EntriesPerPage); int totalPages = Mathf.Max(1, Mathf.CeilToInt(filteredCount / (float)entriesPerPage)); int startEntry = filteredCount == 0 ? 0 : state.CurrentPage * entriesPerPage + 1; int endEntry = Mathf.Min(filteredCount, (state.CurrentPage + 1) * entriesPerPage); using (new EditorGUILayout.HorizontalScope(EditorStyles.helpBox)) { EditorGUILayout.LabelField( $"显示 {startEntry}-{endEntry} / {filteredCount} 条", EditorStyles.miniLabel, GUILayout.Width(140f)); if (filteredCount != totalEntries) { EditorGUILayout.LabelField($"(筛选自 {totalEntries} 条)", EditorStyles.miniLabel, GUILayout.Width(100f)); } GUILayout.FlexibleSpace(); using (new EditorGUI.DisabledScope(state.CurrentPage <= 0)) { if (GUILayout.Button("<<", GUILayout.Width(32f))) { state.CurrentPage = 0; } if (GUILayout.Button("<", GUILayout.Width(28f))) { state.CurrentPage--; } } GUILayout.Label($"第 {state.CurrentPage + 1} / {totalPages} 页", EditorStyles.miniLabel, GUILayout.Width(72f)); using (new EditorGUI.DisabledScope(state.CurrentPage >= totalPages - 1)) { if (GUILayout.Button(">", GUILayout.Width(28f))) { state.CurrentPage++; } if (GUILayout.Button(">>", GUILayout.Width(32f))) { state.CurrentPage = totalPages - 1; } } } } private void DrawEntryElement(int tableIndex, int entryIndex, SerializedProperty entryProperty) { SerializedProperty spriteProperty = entryProperty.FindPropertyRelative(EntrySpritePropertyName); SerializedProperty actionProperty = entryProperty.FindPropertyRelative(EntryActionPropertyName); Sprite sprite = spriteProperty.objectReferenceValue as Sprite; using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox)) { using (new EditorGUILayout.HorizontalScope()) { GUILayout.Label(GetEntryTitle(tableIndex, entryIndex), EditorStyles.boldLabel); GUILayout.FlexibleSpace(); using (new EditorGUI.DisabledScope(entryIndex <= 0)) { if (GUILayout.Button("↑", GUILayout.Width(28f))) { ApplyPendingInspectorChanges(); MoveEntry(tableIndex, entryIndex, entryIndex - 1); GUIUtility.ExitGUI(); } } using (new EditorGUI.DisabledScope(entryIndex >= _database.tables[tableIndex].entries.Count - 1)) { if (GUILayout.Button("↓", GUILayout.Width(28f))) { ApplyPendingInspectorChanges(); MoveEntry(tableIndex, entryIndex, entryIndex + 1); GUIUtility.ExitGUI(); } } using (new EditorGUI.DisabledScope(sprite == null)) { if (GUILayout.Button("Ping", GUILayout.Width(48f))) { EditorGUIUtility.PingObject(sprite); } } if (GUILayout.Button("Remove", GUILayout.Width(64f))) { if (EditorUtility.DisplayDialog("Remove Entry", "Remove the selected entry from the table?", "Remove", "Cancel")) { ApplyPendingInspectorChanges(); RemoveEntry(tableIndex, entryIndex); GUIUtility.ExitGUI(); } } } using (new EditorGUILayout.HorizontalScope()) { Rect previewRect = GUILayoutUtility.GetRect(ListPreviewSize, ListPreviewSize, GUILayout.Width(ListPreviewSize), GUILayout.Height(ListPreviewSize)); DrawSpritePreview(previewRect, sprite); using (new EditorGUILayout.VerticalScope()) { EditorGUILayout.PropertyField(spriteProperty, new GUIContent("Sprite"), true); EditorGUILayout.PropertyField(actionProperty, new GUIContent("Action"), true); } } } } private void DrawSpritePreview(Sprite sprite, string label) { EditorGUILayout.LabelField(label, EditorStyles.miniBoldLabel); Rect previewRect = GUILayoutUtility.GetRect(PreviewSize, PreviewSize, GUILayout.Width(PreviewSize), GUILayout.Height(PreviewSize)); DrawSpritePreview(previewRect, sprite); } private void DrawInlinePropertyWithPreview(Rect rect, GUIContent label, SerializedProperty property, Sprite previewSprite) { float previewWidth = PreviewSize; float gap = 6f; Rect fieldRect = new Rect(rect.x, rect.y, Mathf.Max(60f, rect.width - previewWidth - gap), rect.height); Rect previewRect = new Rect(fieldRect.xMax + gap, rect.y, previewWidth, PreviewSize); EditorGUI.PropertyField(fieldRect, property, label, true); if (Event.current.type == EventType.Repaint || Event.current.type == EventType.Layout) { DrawSpritePreview(previewRect, previewSprite); } } private void DrawSpritePreview(Rect rect, Sprite sprite) { if (sprite == null) { EditorGUI.HelpBox(rect, "None", MessageType.None); return; } Texture2D preview = AssetPreview.GetAssetPreview(sprite); if (preview == null) { preview = AssetPreview.GetMiniThumbnail(sprite); } if (preview != null) { GUI.DrawTexture(rect, preview, ScaleMode.ScaleToFit); } else { EditorGUI.ObjectField(rect, sprite, typeof(Sprite), false); } } private List CollectDatabaseValidationIssues() { List issues = new(); if (_database.tables == null || _database.tables.Count == 0) { issues.Add("数据库中没有任何表,运行时查询将始终回退到占位图标。"); return issues; } List missingTables = GetMissingDefaultTables(); if (missingTables.Count > 0) { issues.Add($"缺少推荐表: {string.Join(", ", missingTables)}。"); } HashSet seenNames = new(StringComparer.OrdinalIgnoreCase); HashSet duplicateNames = new(StringComparer.OrdinalIgnoreCase); for (int i = 0; i < _database.tables.Count; i++) { string tableName = _database.tables[i] != null ? _database.tables[i].deviceName : string.Empty; if (string.IsNullOrWhiteSpace(tableName)) { issues.Add($"表 {i + 1} 的设备名称为空。"); continue; } if (!seenNames.Add(tableName)) { duplicateNames.Add(tableName); } } foreach (string duplicateName in duplicateNames) { issues.Add($"检测到重复的表名 '{duplicateName}'。"); } return issues; } private List CollectTableValidationIssues(int tableIndex) { List issues = new(); if (!IsValidTableIndex(tableIndex)) { issues.Add("当前选中的表无效。"); return issues; } DeviceGlyphTable table = _database.tables[tableIndex]; if (table.entries == null || table.entries.Count == 0) { issues.Add("当前表没有任何条目。"); return issues; } int missingSpriteCount = 0; int missingActionCount = 0; HashSet seenSprites = new(StringComparer.OrdinalIgnoreCase); HashSet duplicateSprites = new(StringComparer.OrdinalIgnoreCase); Dictionary> bindingOwners = new(StringComparer.OrdinalIgnoreCase); for (int i = 0; i < table.entries.Count; i++) { GlyphEntry entry = table.entries[i]; if (entry == null) { issues.Add($"条目 {i + 1} 为空。"); continue; } if (entry.Sprite == null) { missingSpriteCount++; } else if (!seenSprites.Add(entry.Sprite.name)) { duplicateSprites.Add(entry.Sprite.name); } if (entry.action == null) { missingActionCount++; continue; } string entryLabel = entry.Sprite != null ? entry.Sprite.name : $"Entry {i + 1}"; for (int bindingIndex = 0; bindingIndex < entry.action.bindings.Count; bindingIndex++) { InputBinding binding = entry.action.bindings[bindingIndex]; RegisterBindingOwner(bindingOwners, binding.path, entryLabel); RegisterBindingOwner(bindingOwners, binding.effectivePath, entryLabel); } } if (missingSpriteCount > 0) { issues.Add($"{missingSpriteCount} 个条目未绑定 Sprite。"); } if (missingActionCount > 0) { issues.Add($"{missingActionCount} 个条目未绑定 Action,这些条目不会参与运行时路径查找。"); } foreach (string spriteName in duplicateSprites) { issues.Add($"当前表中存在重复的 Sprite 名称 '{spriteName}'。"); } foreach (KeyValuePair> pair in bindingOwners) { if (pair.Value.Count <= 1) { continue; } issues.Add($"绑定 '{pair.Key}' 被多个条目共用: {string.Join(", ", pair.Value)}。运行时只会保留第一个匹配项。"); } return issues; } private void RegisterBindingOwner(Dictionary> bindingOwners, string controlPath, string ownerLabel) { string normalizedPath = InputGlyphDatabase.EditorNormalizeControlPath(controlPath); if (string.IsNullOrEmpty(normalizedPath)) { return; } if (!bindingOwners.TryGetValue(normalizedPath, out List owners)) { owners = new List(); bindingOwners.Add(normalizedPath, owners); } if (!owners.Contains(ownerLabel)) { owners.Add(ownerLabel); } } private void AddEntry(int tableIndex, Sprite sprite) { if (sprite == null || !IsValidTableIndex(tableIndex)) { return; } Undo.RecordObject(_database, "Add glyph entry"); DeviceGlyphTable table = _database.tables[tableIndex]; table.entries ??= new List(); table.entries.Add(new GlyphEntry { Sprite = sprite, action = null }); serializedObject.Update(); InvalidateEntryView(tableIndex); NotifyDatabaseChanged(); } private void ClearEntries(int tableIndex) { if (!IsValidTableIndex(tableIndex)) { return; } Undo.RecordObject(_database, "Clear glyph entries"); DeviceGlyphTable table = _database.tables[tableIndex]; table.entries ??= new List(); table.entries.Clear(); serializedObject.Update(); InvalidateEntryView(tableIndex); NotifyDatabaseChanged(); } private void AddTable(string tableName) { Undo.RecordObject(_database, "Add glyph table"); _database.tables ??= new List(); _database.tables.Add(new DeviceGlyphTable { deviceName = tableName, spriteSheetTexture = null, platformIcons = null, entries = new List() }); SyncTableStates(); serializedObject.Update(); InvalidateAllEntryViews(); NotifyDatabaseChanged(); } private void RemoveTable(int tableIndex) { if (!IsValidTableIndex(tableIndex)) { return; } Undo.RecordObject(_database, "Remove glyph table"); _database.tables.RemoveAt(tableIndex); SyncTableStates(); ClampSelectedTab(); serializedObject.Update(); InvalidateAllEntryViews(); NotifyDatabaseChanged(); } private void CreateMissingDefaultTables() { List missingTables = GetMissingDefaultTables(); if (missingTables.Count == 0) { return; } Undo.RecordObject(_database, "Create standard glyph tables"); _database.tables ??= new List(); for (int i = 0; i < missingTables.Count; i++) { _database.tables.Add(new DeviceGlyphTable { deviceName = missingTables[i], spriteSheetTexture = null, platformIcons = null, entries = new List() }); } SyncTableStates(); serializedObject.Update(); InvalidateAllEntryViews(); NotifyDatabaseChanged(); } private void MergeSpriteSheet(int tableIndex) { if (!IsValidTableIndex(tableIndex)) { return; } DeviceGlyphTable table = _database.tables[tableIndex]; if (table.spriteSheetTexture == null) { EditorUtility.DisplayDialog("Missing Sprite Sheet", "Assign a sprite sheet texture first.", "OK"); return; } string path = AssetDatabase.GetAssetPath(table.spriteSheetTexture); if (string.IsNullOrEmpty(path)) { Debug.LogWarning("[InputGlyphDatabase] Could not resolve the sprite sheet asset path."); return; } UnityEngine.Object[] assets = AssetDatabase.LoadAllAssetsAtPath(path); if (assets == null || assets.Length == 0) { Debug.LogWarning($"[InputGlyphDatabase] No sub-assets found at '{path}'."); return; } List sprites = new(); for (int i = 0; i < assets.Length; i++) { if (assets[i] is Sprite sprite) { sprites.Add(sprite); } } if (sprites.Count == 0) { EditorUtility.DisplayDialog("No Sprites Found", "The selected texture does not contain any sprite sub-assets.", "OK"); return; } Undo.RecordObject(_database, "Merge glyph sprite sheet"); table.entries ??= new List(); Dictionary entriesByName = new(StringComparer.OrdinalIgnoreCase); for (int i = 0; i < table.entries.Count; i++) { GlyphEntry entry = table.entries[i]; if (entry?.Sprite == null) { continue; } if (!entriesByName.ContainsKey(entry.Sprite.name)) { entriesByName.Add(entry.Sprite.name, entry); } } int replaced = 0; int added = 0; for (int i = 0; i < sprites.Count; i++) { Sprite sprite = sprites[i]; if (entriesByName.TryGetValue(sprite.name, out GlyphEntry entry)) { if (entry.Sprite != sprite) { entry.Sprite = sprite; replaced++; } } else { GlyphEntry newEntry = new GlyphEntry { Sprite = sprite, action = null }; table.entries.Add(newEntry); entriesByName.Add(sprite.name, newEntry); added++; } } serializedObject.Update(); InvalidateEntryView(tableIndex); NotifyDatabaseChanged(); Debug.Log($"[InputGlyphDatabase] Merged sprite sheet '{table.spriteSheetTexture.name}' into '{table.deviceName}'. sprites={sprites.Count}, replaced={replaced}, added={added}, total={table.entries.Count}"); } private void ApplyPendingInspectorChanges() { if (serializedObject.ApplyModifiedProperties()) { InvalidateAllEntryViews(); NotifyDatabaseChanged(); } } private void NotifyDatabaseChanged(bool saveAssets = false) { _database.EditorRefreshCache(); EditorUtility.SetDirty(_database); if (saveAssets) { AssetDatabase.SaveAssets(); } } private void SyncTableStates() { int count = TableCount; if (_tableStates.Count == count) { return; } _tableStates.Clear(); for (int i = 0; i < count; i++) { _tableStates.Add(new TableEditorState()); } } private void ClampSelectedTab() { int maxIndex = Mathf.Max(0, TableCount); _selectedTab = Mathf.Clamp(_selectedTab, 0, maxIndex); } private void MoveEntry(int tableIndex, int fromIndex, int toIndex) { if (!IsValidTableIndex(tableIndex)) { return; } List entries = _database.tables[tableIndex].entries; if (entries == null || fromIndex < 0 || fromIndex >= entries.Count || toIndex < 0 || toIndex >= entries.Count || fromIndex == toIndex) { return; } Undo.RecordObject(_database, "Move glyph entry"); GlyphEntry entry = entries[fromIndex]; entries.RemoveAt(fromIndex); entries.Insert(toIndex, entry); serializedObject.Update(); InvalidateEntryView(tableIndex); NotifyDatabaseChanged(); } private void RemoveEntry(int tableIndex, int entryIndex) { if (!IsValidTableIndex(tableIndex)) { return; } List entries = _database.tables[tableIndex].entries; if (entries == null || entryIndex < 0 || entryIndex >= entries.Count) { return; } Undo.RecordObject(_database, "Remove glyph entry"); entries.RemoveAt(entryIndex); serializedObject.Update(); InvalidateEntryView(tableIndex); NotifyDatabaseChanged(); } private List GetFilteredEntryIndices(int tableIndex, SerializedProperty entriesProperty, TableEditorState state) { string search = state.EntrySearch != null ? state.EntrySearch.Trim() : string.Empty; if (state.CachedEntryCount == entriesProperty.arraySize && string.Equals(state.CachedSearch, search, StringComparison.Ordinal)) { return state.FilteredEntryIndices; } state.FilteredEntryIndices.Clear(); for (int i = 0; i < entriesProperty.arraySize; i++) { if (DoesEntryMatchSearch(tableIndex, i, search)) { state.FilteredEntryIndices.Add(i); } } state.CachedEntryCount = entriesProperty.arraySize; state.CachedSearch = search; return state.FilteredEntryIndices; } private bool DoesEntryMatchSearch(int tableIndex, int entryIndex, string search) { if (string.IsNullOrWhiteSpace(search)) { return true; } if (!IsValidTableIndex(tableIndex)) { return false; } List entries = _database.tables[tableIndex].entries; if (entries == null || entryIndex < 0 || entryIndex >= entries.Count) { return false; } GlyphEntry entry = entries[entryIndex]; if (entry == null) { return ContainsIgnoreCase("null", search); } if (ContainsIgnoreCase(entry.Sprite != null ? entry.Sprite.name : string.Empty, search) || ContainsIgnoreCase(entry.action != null ? entry.action.name : string.Empty, search)) { return true; } if (entry.action == null) { return false; } for (int i = 0; i < entry.action.bindings.Count; i++) { InputBinding binding = entry.action.bindings[i]; if (ContainsIgnoreCase(binding.path, search) || ContainsIgnoreCase(binding.effectivePath, search)) { return true; } } return false; } private static bool ContainsIgnoreCase(string value, string search) { return !string.IsNullOrEmpty(value) && value.IndexOf(search, StringComparison.OrdinalIgnoreCase) >= 0; } private string GetEntryTitle(int tableIndex, int entryIndex) { if (!IsValidTableIndex(tableIndex)) { return $"Entry #{entryIndex + 1}"; } List entries = _database.tables[tableIndex].entries; if (entries == null || entryIndex < 0 || entryIndex >= entries.Count) { return $"Entry #{entryIndex + 1}"; } GlyphEntry entry = entries[entryIndex]; string spriteName = entry?.Sprite != null ? entry.Sprite.name : "No Sprite"; string actionName = entry?.action != null ? entry.action.name : "No Action"; return $"#{entryIndex + 1} {spriteName} / {actionName}"; } private void InvalidateEntryView(int tableIndex) { if (tableIndex < 0 || tableIndex >= _tableStates.Count) { return; } InvalidateEntryView(_tableStates[tableIndex]); } private static void InvalidateEntryView(TableEditorState state) { state.FilteredEntryIndices.Clear(); state.CachedSearch = string.Empty; state.CachedEntryCount = -1; } private void InvalidateAllEntryViews() { for (int i = 0; i < _tableStates.Count; i++) { InvalidateEntryView(_tableStates[i]); } } private bool IsValidTableIndex(int tableIndex) { return _database != null && _database.tables != null && tableIndex >= 0 && tableIndex < _database.tables.Count; } private string GetTableName(SerializedProperty tableProperty, int fallbackIndex) { SerializedProperty nameProperty = tableProperty.FindPropertyRelative(DeviceNamePropertyName); return string.IsNullOrWhiteSpace(nameProperty.stringValue) ? $"Table {fallbackIndex + 1}" : nameProperty.stringValue; } private bool HasTable(string tableName) { if (string.IsNullOrWhiteSpace(tableName) || _database.tables == null) { return false; } for (int i = 0; i < _database.tables.Count; i++) { if (string.Equals(_database.tables[i].deviceName, tableName, StringComparison.OrdinalIgnoreCase)) { return true; } } return false; } private bool HasDuplicateTableName(string tableName, int selfIndex) { if (string.IsNullOrWhiteSpace(tableName) || _database.tables == null) { return false; } for (int i = 0; i < _database.tables.Count; i++) { if (i == selfIndex) { continue; } if (string.Equals(_database.tables[i].deviceName, tableName, StringComparison.OrdinalIgnoreCase)) { return true; } } return false; } private bool HasMissingDefaultTables() { return GetMissingDefaultTables().Count > 0; } private List GetMissingDefaultTables() { List missingTables = new(); for (int i = 0; i < DefaultTableNames.Length; i++) { if (!HasTable(DefaultTableNames[i])) { missingTables.Add(DefaultTableNames[i]); } } return missingTables; } }