641 lines
21 KiB
C#
641 lines
21 KiB
C#
using System;
|
||
using UnityEditor;
|
||
using UnityEngine;
|
||
using UnityEngine.U2D;
|
||
using TMPro;
|
||
using System.Reflection;
|
||
using System.Collections.Generic;
|
||
using UnityEngine.InputSystem;
|
||
using InputGlyphsFramework;
|
||
using System.IO;
|
||
using System.Linq;
|
||
using UnityEngine.InputSystem.Layouts;
|
||
|
||
[CustomEditor(typeof(InputGlyphDatabase))]
|
||
public class InputGlyphDatabaseEditor : Editor
|
||
{
|
||
InputGlyphDatabase db;
|
||
int tabIndex = 0;
|
||
string[] tabNames = new string[] { "Keyboard", "Xbox", "PlayStation", "Other" };
|
||
|
||
void OnEnable()
|
||
{
|
||
db = target as InputGlyphDatabase;
|
||
}
|
||
|
||
public override void OnInspectorGUI()
|
||
{
|
||
serializedObject.Update();
|
||
if (db == null) return;
|
||
EditorGUILayout.Space();
|
||
tabIndex = GUILayout.Toolbar(tabIndex, tabNames, GUILayout.Height(24));
|
||
var curDevice = (InputDeviceWatcher.InputDeviceCategory)tabIndex;
|
||
|
||
if (db.tables == null) db.tables = new List<DeviceGlyphTable>();
|
||
EnsureTableFor(InputDeviceWatcher.InputDeviceCategory.Keyboard);
|
||
EnsureTableFor(InputDeviceWatcher.InputDeviceCategory.Xbox);
|
||
EnsureTableFor(InputDeviceWatcher.InputDeviceCategory.PlayStation);
|
||
EnsureTableFor(InputDeviceWatcher.InputDeviceCategory.Other);
|
||
|
||
var table = db.GetTable(curDevice);
|
||
if (table == null) return;
|
||
|
||
EditorGUILayout.BeginVertical("box");
|
||
EditorGUILayout.LabelField(curDevice.ToString(), EditorStyles.boldLabel);
|
||
table.tmpAsset = EditorGUILayout.ObjectField("TMP Sprite Asset", table.tmpAsset, typeof(TMP_SpriteAsset), false) as TMP_SpriteAsset;
|
||
|
||
EditorGUILayout.BeginHorizontal();
|
||
if (GUILayout.Button("Parse TMP Asset"))
|
||
{
|
||
ParseTMPAssetIntoTable(table);
|
||
}
|
||
|
||
if (GUILayout.Button("Clear"))
|
||
{
|
||
table.entries.Clear();
|
||
EditorUtility.SetDirty(db);
|
||
}
|
||
|
||
EditorGUILayout.EndHorizontal();
|
||
|
||
EditorGUILayout.Space();
|
||
|
||
if (table.entries != null)
|
||
{
|
||
for (int i = 0; i < table.entries.Count; ++i)
|
||
{
|
||
var e = table.entries[i];
|
||
if (e == null) continue;
|
||
|
||
EditorGUILayout.BeginHorizontal(GUILayout.Height(64));
|
||
if (e.Sprite != null)
|
||
{
|
||
Texture2D preview = AssetPreview.GetAssetPreview(e.Sprite);
|
||
if (preview == null) preview = AssetPreview.GetMiniThumbnail(e.Sprite);
|
||
if (preview != null) GUILayout.Label(preview, GUILayout.Width(64), GUILayout.Height(64));
|
||
else EditorGUILayout.ObjectField(e.Sprite, typeof(Sprite), false, GUILayout.Width(64), GUILayout.Height(64));
|
||
}
|
||
else
|
||
{
|
||
EditorGUILayout.ObjectField(null, typeof(Sprite), false, GUILayout.Width(64), GUILayout.Height(64));
|
||
}
|
||
|
||
EditorGUILayout.BeginVertical();
|
||
EditorGUILayout.LabelField(e.Sprite != null ? e.Sprite.name : "<missing>", EditorStyles.boldLabel);
|
||
|
||
EditorGUILayout.BeginHorizontal();
|
||
EditorGUILayout.LabelField("ControlPath", GUILayout.Width(80));
|
||
EditorGUILayout.SelectableLabel(e.controlPath ?? string.Empty, GUILayout.Height(16), GUILayout.Width(300));
|
||
|
||
Rect btnRect = GUILayoutUtility.GetRect(60, 18, GUILayout.Width(60));
|
||
if (GUI.Button(btnRect, "Bind"))
|
||
{
|
||
ControlPathPickerPopup.ShowDropdown(btnRect, curDevice, (selectedPath) =>
|
||
{
|
||
if (!string.IsNullOrEmpty(selectedPath))
|
||
{
|
||
e.controlPath = selectedPath;
|
||
EditorUtility.SetDirty(db);
|
||
AssetDatabase.SaveAssets();
|
||
}
|
||
});
|
||
}
|
||
|
||
if (GUILayout.Button("Clear", GUILayout.Width(48)))
|
||
{
|
||
e.controlPath = string.Empty;
|
||
EditorUtility.SetDirty(db);
|
||
AssetDatabase.SaveAssets();
|
||
}
|
||
|
||
EditorGUILayout.EndHorizontal();
|
||
|
||
EditorGUILayout.EndVertical();
|
||
EditorGUILayout.EndHorizontal();
|
||
EditorGUILayout.Space(6);
|
||
}
|
||
}
|
||
|
||
EditorGUILayout.BeginHorizontal();
|
||
if (GUILayout.Button("Add Entry"))
|
||
{
|
||
table.entries.Add(new GlyphEntry { Sprite = null, controlPath = string.Empty });
|
||
EditorUtility.SetDirty(db);
|
||
}
|
||
|
||
if (GUILayout.Button("Remove Nulls"))
|
||
{
|
||
table.entries.RemoveAll(x => x == null || x.Sprite == null);
|
||
EditorUtility.SetDirty(db);
|
||
}
|
||
|
||
EditorGUILayout.EndHorizontal();
|
||
|
||
EditorGUILayout.EndVertical();
|
||
|
||
EditorGUILayout.Space();
|
||
if (GUILayout.Button("Save Asset"))
|
||
{
|
||
EditorUtility.SetDirty(db);
|
||
AssetDatabase.SaveAssets();
|
||
}
|
||
|
||
serializedObject.ApplyModifiedProperties();
|
||
}
|
||
|
||
void EnsureTableFor(InputDeviceWatcher.InputDeviceCategory device)
|
||
{
|
||
if (db.GetTable(device) == null)
|
||
{
|
||
var t = new DeviceGlyphTable { deviceType = device, tmpAsset = null, entries = new List<GlyphEntry>() };
|
||
db.tables.Add(t);
|
||
EditorUtility.SetDirty(db);
|
||
}
|
||
}
|
||
|
||
void ParseTMPAssetIntoTable(DeviceGlyphTable table)
|
||
{
|
||
if (table == null || table.tmpAsset == null) return;
|
||
var asset = table.tmpAsset;
|
||
table.entries = new List<GlyphEntry>();
|
||
var chars = asset.spriteCharacterTable;
|
||
SpriteAtlas atlas = GetSpriteAtlasFromTMP(asset);
|
||
string assetPath = AssetDatabase.GetAssetPath(asset);
|
||
string assetFolder = Path.GetDirectoryName(assetPath);
|
||
for (int i = 0; i < chars.Count; ++i)
|
||
{
|
||
var ch = chars[i];
|
||
if (ch == null) continue;
|
||
var name = ch.name;
|
||
if (string.IsNullOrEmpty(name)) continue;
|
||
Sprite s = null;
|
||
try
|
||
{
|
||
var glyph = ch.glyph as TMP_SpriteGlyph;
|
||
if (glyph != null && glyph.sprite != null) s = glyph.sprite;
|
||
}
|
||
catch
|
||
{
|
||
}
|
||
|
||
if (s == null && atlas != null)
|
||
{
|
||
try
|
||
{
|
||
s = atlas.GetSprite(name);
|
||
}
|
||
catch
|
||
{
|
||
s = null;
|
||
}
|
||
|
||
if (s == null)
|
||
{
|
||
try
|
||
{
|
||
var m = typeof(SpriteAtlas).GetMethod("GetSprite", new System.Type[] { typeof(string) });
|
||
if (m != null) s = m.Invoke(atlas, new object[] { name }) as Sprite;
|
||
}
|
||
catch
|
||
{
|
||
}
|
||
}
|
||
}
|
||
|
||
if (s == null)
|
||
{
|
||
string[] scoped = AssetDatabase.FindAssets(name + " t:Sprite", new[] { assetFolder });
|
||
if (scoped != null && scoped.Length > 0)
|
||
{
|
||
foreach (var g in scoped)
|
||
{
|
||
var p = AssetDatabase.GUIDToAssetPath(g);
|
||
var sp = AssetDatabase.LoadAssetAtPath<Sprite>(p);
|
||
if (sp != null && sp.name == name)
|
||
{
|
||
s = sp;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (s == null)
|
||
{
|
||
string[] all = AssetDatabase.FindAssets(name + " t:Sprite");
|
||
if (all != null && all.Length > 0)
|
||
{
|
||
foreach (var g in all)
|
||
{
|
||
var p = AssetDatabase.GUIDToAssetPath(g);
|
||
var sp = AssetDatabase.LoadAssetAtPath<Sprite>(p);
|
||
if (sp != null && sp.name == name)
|
||
{
|
||
s = sp;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
table.entries.Add(new GlyphEntry { Sprite = s, controlPath = string.Empty });
|
||
}
|
||
|
||
EditorUtility.SetDirty(db);
|
||
AssetDatabase.SaveAssets();
|
||
}
|
||
|
||
SpriteAtlas GetSpriteAtlasFromTMP(TMP_SpriteAsset asset)
|
||
{
|
||
if (asset == null) return null;
|
||
var t = asset.GetType();
|
||
foreach (var f in t.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic))
|
||
{
|
||
if (typeof(SpriteAtlas).IsAssignableFrom(f.FieldType))
|
||
{
|
||
var val = f.GetValue(asset) as SpriteAtlas;
|
||
if (val != null) return val;
|
||
}
|
||
}
|
||
|
||
foreach (var p in t.GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic))
|
||
{
|
||
if (typeof(SpriteAtlas).IsAssignableFrom(p.PropertyType))
|
||
{
|
||
try
|
||
{
|
||
var val = p.GetValue(asset, null) as SpriteAtlas;
|
||
if (val != null) return val;
|
||
}
|
||
catch
|
||
{
|
||
}
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
}
|
||
|
||
|
||
public class ControlPathPickerPopup : EditorWindow
|
||
{
|
||
InputDeviceWatcher.InputDeviceCategory device;
|
||
Action<string> onSelect;
|
||
string search = "";
|
||
Vector2 scroll;
|
||
Dictionary<string, List<string>> groups = new Dictionary<string, List<string>>();
|
||
Dictionary<string, List<string>> displayGroups = new Dictionary<string, List<string>>();
|
||
Dictionary<string, bool> foldouts = new Dictionary<string, bool>();
|
||
bool listening = false;
|
||
IDisposable anyButtonSubscription;
|
||
|
||
public static void ShowDropdown(Rect anchorRect, InputDeviceWatcher.InputDeviceCategory device, Action<string> onSelect)
|
||
{
|
||
var w = CreateInstance<ControlPathPickerPopup>();
|
||
w.device = device;
|
||
w.onSelect = onSelect;
|
||
w.titleContent = new GUIContent("Pick Control Path");
|
||
w.minSize = new Vector2(480, 420);
|
||
w.InitListsDynamic();
|
||
w.ShowAsDropDown(anchorRect, new Vector2(480, 420));
|
||
}
|
||
|
||
void OnEnable()
|
||
{
|
||
EditorApplication.update += EditorUpdate;
|
||
}
|
||
|
||
void OnDisable()
|
||
{
|
||
StopListening();
|
||
EditorApplication.update -= EditorUpdate;
|
||
}
|
||
|
||
void EditorUpdate()
|
||
{
|
||
if (listening) Repaint();
|
||
}
|
||
|
||
// -------------------- 动态构建列表 --------------------
|
||
void InitListsDynamic()
|
||
{
|
||
groups.Clear();
|
||
displayGroups.Clear();
|
||
foldouts.Clear();
|
||
|
||
// 首先插入一些基本的静态分组占位(如果需要)
|
||
var known = new[] { "Keyboard", "Gamepad", "Mouse", "Joystick", "Touchscreen", "XR Controller", "XR HMD", "Pointer", "Pen", "Other" };
|
||
foreach (var k in known)
|
||
{
|
||
groups[k] = new List<string>();
|
||
displayGroups[k] = new List<string>();
|
||
foldouts[k] = true;
|
||
}
|
||
|
||
// 列出所有已注册布局名称
|
||
IEnumerable<string> layoutNames;
|
||
try
|
||
{
|
||
layoutNames = InputSystem.ListLayouts();
|
||
}
|
||
catch
|
||
{
|
||
layoutNames = Enumerable.Empty<string>();
|
||
}
|
||
|
||
foreach (var layoutName in layoutNames)
|
||
{
|
||
InputControlLayout layout = null;
|
||
try
|
||
{
|
||
layout = InputSystem.LoadLayout(layoutName);
|
||
}
|
||
catch
|
||
{
|
||
layout = null;
|
||
}
|
||
|
||
if (layout == null) continue;
|
||
|
||
// 遍历 layout.controls (InputControlLayout.ControlItem)
|
||
try
|
||
{
|
||
var controls = layout.controls;
|
||
if (controls.Count == 0) continue;
|
||
foreach (var ci in controls)
|
||
{
|
||
// isModifyingExistingControl 表示这个 control 只是修改已有 control 的属性,不引入新的控制项,通常跳过
|
||
if (ci.isModifyingExistingControl) continue;
|
||
|
||
string controlName = ci.name.ToString();
|
||
if (string.IsNullOrEmpty(controlName)) continue;
|
||
|
||
string path = $"<{layoutName}>/{controlName}";
|
||
|
||
// 决定分组:根据 layoutName 或 controlName 做简单匹配
|
||
string group = DecideGroupForLayout(layoutName, controlName);
|
||
|
||
if (!groups.ContainsKey(group))
|
||
{
|
||
groups[group] = new List<string>();
|
||
displayGroups[group] = new List<string>();
|
||
foldouts[group] = true;
|
||
}
|
||
|
||
// 防重复
|
||
if (!groups[group].Contains(path))
|
||
{
|
||
groups[group].Add(path);
|
||
displayGroups[group].Add($"{controlName} ({path})");
|
||
}
|
||
|
||
// 试着添加常见的子项(例如 stick/up/down 等),如果 controlItem 标识这些为子控制可能需要额外处理
|
||
// 但大多数情况下 layout.controls 已包含独立条目如 leftStick/up
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
/* 忽略个别布局异常 */
|
||
}
|
||
}
|
||
|
||
// 额外补充:把一些常用手动添加的路径放到 Gamepad / Keyboard 中,防止某些 layout 未定义具体按钮
|
||
AddFallbackCommonPaths();
|
||
}
|
||
|
||
string DecideGroupForLayout(string layoutName, string controlName)
|
||
{
|
||
var lname = layoutName.ToLowerInvariant();
|
||
if (lname.Contains("keyboard")) return "Keyboard";
|
||
if (lname.Contains("gamepad") || lname.Contains("xbox") || lname.Contains("dualshock") || lname.Contains("controller")) return "Gamepad";
|
||
if (lname.Contains("mouse")) return "Mouse";
|
||
if (lname.Contains("joystick")) return "Joystick";
|
||
if (lname.Contains("touchscreen")) return "Touchscreen";
|
||
if (lname.Contains("xr") || lname.Contains("hmd")) return "XR Controller";
|
||
return "Other";
|
||
}
|
||
|
||
void AddFallbackCommonPaths()
|
||
{
|
||
void addIfMissing(string grp, string path, string label = null)
|
||
{
|
||
if (!groups.ContainsKey(grp))
|
||
{
|
||
groups[grp] = new List<string>();
|
||
displayGroups[grp] = new List<string>();
|
||
foldouts[grp] = true;
|
||
}
|
||
|
||
if (!groups[grp].Contains(path))
|
||
{
|
||
groups[grp].Add(path);
|
||
displayGroups[grp].Add(label ?? path);
|
||
}
|
||
}
|
||
|
||
addIfMissing("Gamepad", "<Gamepad>/buttonSouth");
|
||
addIfMissing("Gamepad", "<Gamepad>/buttonEast");
|
||
addIfMissing("Gamepad", "<Gamepad>/buttonWest");
|
||
addIfMissing("Gamepad", "<Gamepad>/buttonNorth");
|
||
addIfMissing("Gamepad", "<Gamepad>/leftShoulder");
|
||
addIfMissing("Gamepad", "<Gamepad>/rightShoulder");
|
||
addIfMissing("Gamepad", "<Gamepad>/leftTrigger");
|
||
addIfMissing("Gamepad", "<Gamepad>/rightTrigger");
|
||
addIfMissing("Keyboard", "<Keyboard>/space");
|
||
addIfMissing("Keyboard", "<Keyboard>/enter");
|
||
addIfMissing("Keyboard", "<Keyboard>/escape");
|
||
addIfMissing("Mouse", "<Mouse>/leftButton");
|
||
}
|
||
|
||
// -------------------- UI --------------------
|
||
void OnGUI()
|
||
{
|
||
EditorGUILayout.BeginVertical();
|
||
EditorGUILayout.BeginHorizontal();
|
||
var prevColor = GUI.color;
|
||
search = EditorGUILayout.TextField(search, GUILayout.ExpandWidth(true));
|
||
if (!listening)
|
||
{
|
||
if (GUILayout.Button("Listen", GUILayout.Width(72)))
|
||
{
|
||
StartListening();
|
||
}
|
||
}
|
||
else
|
||
{
|
||
GUI.color = Color.yellow;
|
||
if (GUILayout.Button("Stop", GUILayout.Width(72)))
|
||
{
|
||
StopListening();
|
||
}
|
||
|
||
GUI.color = prevColor;
|
||
}
|
||
|
||
EditorGUILayout.EndHorizontal();
|
||
|
||
EditorGUILayout.Space();
|
||
|
||
scroll = EditorGUILayout.BeginScrollView(scroll);
|
||
foreach (var kv in displayGroups.OrderBy(k => k.Key))
|
||
{
|
||
string cat = kv.Key;
|
||
if (!ShouldShowCategory(cat)) continue;
|
||
|
||
foldouts.TryGetValue(cat, out bool f);
|
||
f = EditorGUILayout.Foldout(f, cat, true);
|
||
foldouts[cat] = f;
|
||
if (!f) continue;
|
||
|
||
var displays = kv.Value;
|
||
var vals = groups[cat];
|
||
for (int i = 0; i < displays.Count; ++i)
|
||
{
|
||
var dsp = displays[i];
|
||
var val = vals[i];
|
||
if (!MatchSearch(dsp, val)) continue;
|
||
|
||
EditorGUILayout.BeginHorizontal();
|
||
if (GUILayout.Button("Select", GUILayout.Width(64)))
|
||
{
|
||
onSelect?.Invoke(val);
|
||
Close();
|
||
return;
|
||
}
|
||
|
||
EditorGUILayout.LabelField(dsp, GUILayout.ExpandWidth(true));
|
||
EditorGUILayout.EndHorizontal();
|
||
}
|
||
|
||
EditorGUILayout.Space(6);
|
||
}
|
||
|
||
EditorGUILayout.EndScrollView();
|
||
|
||
EditorGUILayout.Space();
|
||
GUILayout.FlexibleSpace();
|
||
if (GUILayout.Button("Close")) Close();
|
||
EditorGUILayout.EndVertical();
|
||
}
|
||
|
||
bool ShouldShowCategory(string cat)
|
||
{
|
||
if (string.IsNullOrEmpty(search)) return true;
|
||
if (cat.IndexOf(search, StringComparison.OrdinalIgnoreCase) >= 0) return true;
|
||
var disp = displayGroups[cat];
|
||
for (int i = 0; i < disp.Count; ++i)
|
||
if (MatchSearch(disp[i], groups[cat][i]))
|
||
return true;
|
||
return false;
|
||
}
|
||
|
||
bool MatchSearch(string display, string value)
|
||
{
|
||
if (string.IsNullOrEmpty(search)) return true;
|
||
if (display.IndexOf(search, StringComparison.OrdinalIgnoreCase) >= 0) return true;
|
||
if (value.IndexOf(search, StringComparison.OrdinalIgnoreCase) >= 0) return true;
|
||
return false;
|
||
}
|
||
|
||
// -------------------- Listen (使用 IObserver<T> 订阅) --------------------
|
||
class InputControlObserver : IObserver<InputControl>
|
||
{
|
||
readonly Action<InputControl> onNext;
|
||
|
||
public InputControlObserver(Action<InputControl> onNext)
|
||
{
|
||
this.onNext = onNext;
|
||
}
|
||
|
||
public void OnCompleted()
|
||
{
|
||
}
|
||
|
||
public void OnError(Exception error)
|
||
{
|
||
}
|
||
|
||
public void OnNext(InputControl value)
|
||
{
|
||
try
|
||
{
|
||
onNext?.Invoke(value);
|
||
}
|
||
catch
|
||
{
|
||
}
|
||
}
|
||
}
|
||
|
||
void StartListening()
|
||
{
|
||
if (listening) return;
|
||
listening = true;
|
||
try
|
||
{
|
||
// 使用 IObserver<InputControl> 订阅(通用、可靠)
|
||
anyButtonSubscription = InputSystem.onAnyButtonPress.Subscribe(new InputControlObserver(control =>
|
||
{
|
||
if (control == null) return;
|
||
string path = control.path;
|
||
onSelect?.Invoke(path);
|
||
StopListening();
|
||
Close();
|
||
}));
|
||
}
|
||
catch (Exception)
|
||
{
|
||
// 某些版本/包装可能提供 Call/CallOnce 扩展方法(Utilities),尝试反射调用这些扩展
|
||
try
|
||
{
|
||
var t = typeof(InputSystem);
|
||
var prop = t.GetProperty("onAnyButtonPress");
|
||
if (prop != null)
|
||
{
|
||
var obs = prop.GetValue(null);
|
||
var callMethod = obs?.GetType().GetMethod("CallOnce") ?? obs?.GetType().GetMethod("Call");
|
||
if (callMethod != null)
|
||
{
|
||
// 尝试调用 CallOnce(Action<InputControl>)
|
||
callMethod.Invoke(obs, new object[]
|
||
{
|
||
new Action<InputControl>(c =>
|
||
{
|
||
if (c == null) return;
|
||
onSelect?.Invoke(c.path);
|
||
StopListening();
|
||
Close();
|
||
})
|
||
});
|
||
StopListening();
|
||
Close();
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
}
|
||
|
||
// 如果都失败,简单关闭监听状态
|
||
listening = false;
|
||
}
|
||
}
|
||
|
||
void StopListening()
|
||
{
|
||
if (!listening) return;
|
||
listening = false;
|
||
try
|
||
{
|
||
anyButtonSubscription?.Dispose();
|
||
anyButtonSubscription = null;
|
||
}
|
||
catch
|
||
{
|
||
anyButtonSubscription = null;
|
||
}
|
||
}
|
||
}
|