From 175a96c230faad04491be0c8234a3a2851dd4d15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=80=9D=E6=B5=B7?= <1464576565@qq.com> Date: Fri, 20 Mar 2026 16:50:30 +0800 Subject: [PATCH] =?UTF-8?q?InputGlyph=E6=A8=A1=E5=9D=97=20=E5=90=88?= =?UTF-8?q?=E5=B9=B6=E5=85=A5UI=E6=89=A9=E5=B1=95=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Runtime/InputGlyph.meta | 8 + Runtime/InputGlyph/Core.meta | 8 + Runtime/InputGlyph/Core/GlyphService.cs | 366 +++++ Runtime/InputGlyph/Core/GlyphService.cs.meta | 3 + Runtime/InputGlyph/Core/InputActionReader.cs | 300 +++++ .../InputGlyph/Core/InputActionReader.cs.meta | 3 + .../InputGlyph/Core/InputBindingManager.cs | 844 ++++++++++++ .../Core/InputBindingManager.cs.meta | 3 + Runtime/InputGlyph/Core/InputDeviceWatcher.cs | 476 +++++++ .../Core/InputDeviceWatcher.cs.meta | 11 + Runtime/InputGlyph/Data.meta | 8 + Runtime/InputGlyph/Data/InputGlyphDatabase.cs | 359 +++++ .../Data/InputGlyphDatabase.cs.meta | 3 + Runtime/InputGlyph/Editor.meta | 8 + .../Editor/InputGlyphDatabaseEditor.cs | 1189 +++++++++++++++++ .../Editor/InputGlyphDatabaseEditor.cs.meta | 3 + Runtime/InputGlyph/Editor/InputGlyphEditor.cs | 303 +++++ .../Editor/InputGlyphEditor.cs.meta | 11 + Runtime/InputGlyph/InputGlyph.cs | 296 ++++ Runtime/InputGlyph/InputGlyph.cs.meta | 11 + Runtime/InputGlyph/InputGlyphBehaviourBase.cs | 41 + .../InputGlyphBehaviourBase.cs.meta | 11 + Runtime/InputGlyph/TestRebindScript.cs | 245 ++++ Runtime/InputGlyph/TestRebindScript.cs.meta | 11 + 24 files changed, 4521 insertions(+) create mode 100644 Runtime/InputGlyph.meta create mode 100644 Runtime/InputGlyph/Core.meta create mode 100644 Runtime/InputGlyph/Core/GlyphService.cs create mode 100644 Runtime/InputGlyph/Core/GlyphService.cs.meta create mode 100644 Runtime/InputGlyph/Core/InputActionReader.cs create mode 100644 Runtime/InputGlyph/Core/InputActionReader.cs.meta create mode 100644 Runtime/InputGlyph/Core/InputBindingManager.cs create mode 100644 Runtime/InputGlyph/Core/InputBindingManager.cs.meta create mode 100644 Runtime/InputGlyph/Core/InputDeviceWatcher.cs create mode 100644 Runtime/InputGlyph/Core/InputDeviceWatcher.cs.meta create mode 100644 Runtime/InputGlyph/Data.meta create mode 100644 Runtime/InputGlyph/Data/InputGlyphDatabase.cs create mode 100644 Runtime/InputGlyph/Data/InputGlyphDatabase.cs.meta create mode 100644 Runtime/InputGlyph/Editor.meta create mode 100644 Runtime/InputGlyph/Editor/InputGlyphDatabaseEditor.cs create mode 100644 Runtime/InputGlyph/Editor/InputGlyphDatabaseEditor.cs.meta create mode 100644 Runtime/InputGlyph/Editor/InputGlyphEditor.cs create mode 100644 Runtime/InputGlyph/Editor/InputGlyphEditor.cs.meta create mode 100644 Runtime/InputGlyph/InputGlyph.cs create mode 100644 Runtime/InputGlyph/InputGlyph.cs.meta create mode 100644 Runtime/InputGlyph/InputGlyphBehaviourBase.cs create mode 100644 Runtime/InputGlyph/InputGlyphBehaviourBase.cs.meta create mode 100644 Runtime/InputGlyph/TestRebindScript.cs create mode 100644 Runtime/InputGlyph/TestRebindScript.cs.meta diff --git a/Runtime/InputGlyph.meta b/Runtime/InputGlyph.meta new file mode 100644 index 0000000..ffb1d04 --- /dev/null +++ b/Runtime/InputGlyph.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 22b092f05744baa4383a9dc15a488e22 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/InputGlyph/Core.meta b/Runtime/InputGlyph/Core.meta new file mode 100644 index 0000000..367b004 --- /dev/null +++ b/Runtime/InputGlyph/Core.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: bb7e653c16f0005419e1eb9226655176 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/InputGlyph/Core/GlyphService.cs b/Runtime/InputGlyph/Core/GlyphService.cs new file mode 100644 index 0000000..5028dd0 --- /dev/null +++ b/Runtime/InputGlyph/Core/GlyphService.cs @@ -0,0 +1,366 @@ +using System; +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.InputSystem; + +public static class GlyphService +{ + private static readonly string[] KeyboardGroupHints = { "keyboard", "mouse", "keyboard&mouse", "keyboardmouse", "kbm" }; + private static readonly string[] XboxGroupHints = { "xbox", "xinput", "gamepad", "controller" }; + private static readonly string[] PlayStationGroupHints = { "playstation", "dualshock", "dualsense", "gamepad", "controller" }; + private static readonly string[] OtherGamepadGroupHints = { "gamepad", "controller", "joystick" }; + private static readonly char[] TrimChars = { '{', '}', '<', '>', '\'', '"' }; + private static readonly Dictionary DisplayNameCache = new(StringComparer.Ordinal); + private static readonly Dictionary SpriteTagCache = new(); + + private static InputGlyphDatabase _database; + + public static InputGlyphDatabase Database + { + get + { + if (_database == null) + { + _database = Resources.Load("InputGlyphDatabase"); + } + + return _database; + } + } + + public static string GetBindingControlPath( + InputAction action, + string compositePartName = null, + InputDeviceWatcher.InputDeviceCategory? deviceOverride = null) + { + return TryGetBindingControl(action, compositePartName, deviceOverride, out InputBinding binding) + ? GetEffectivePath(binding) + : string.Empty; + } + + public static string GetBindingControlPath( + InputActionReference actionReference, + string compositePartName = null, + InputDeviceWatcher.InputDeviceCategory? deviceOverride = null) + { + return GetBindingControlPath(actionReference != null ? actionReference.action : null, compositePartName, deviceOverride); + } + + public static bool TryGetTMPTagForActionPath( + InputAction action, + string compositePartName, + InputDeviceWatcher.InputDeviceCategory device, + out string tag, + out string displayFallback, + InputGlyphDatabase db = null) + { + string controlPath = GetBindingControlPath(action, compositePartName, device); + return TryGetTMPTagForActionPath(controlPath, device, out tag, out displayFallback, db); + } + + public static bool TryGetTMPTagForActionPath( + InputActionReference actionReference, + string compositePartName, + InputDeviceWatcher.InputDeviceCategory device, + out string tag, + out string displayFallback, + InputGlyphDatabase db = null) + { + return TryGetTMPTagForActionPath(actionReference != null ? actionReference.action : null, compositePartName, device, out tag, out displayFallback, db); + } + + public static bool TryGetUISpriteForActionPath( + InputAction action, + string compositePartName, + InputDeviceWatcher.InputDeviceCategory device, + out Sprite sprite, + InputGlyphDatabase db = null) + { + string controlPath = GetBindingControlPath(action, compositePartName, device); + return TryGetUISpriteForActionPath(controlPath, device, out sprite, db); + } + + public static bool TryGetUISpriteForActionPath( + InputActionReference actionReference, + string compositePartName, + InputDeviceWatcher.InputDeviceCategory device, + out Sprite sprite, + InputGlyphDatabase db = null) + { + return TryGetUISpriteForActionPath(actionReference != null ? actionReference.action : null, compositePartName, device, out sprite, db); + } + + public static bool TryGetTMPTagForActionPath( + string controlPath, + InputDeviceWatcher.InputDeviceCategory device, + out string tag, + out string displayFallback, + InputGlyphDatabase db = null) + { + displayFallback = GetDisplayNameFromControlPath(controlPath); + tag = null; + + if (!TryGetUISpriteForActionPath(controlPath, device, out Sprite sprite, db)) + { + return false; + } + + tag = GetSpriteTag(sprite); + return true; + } + + public static bool TryGetUISpriteForActionPath( + string controlPath, + InputDeviceWatcher.InputDeviceCategory device, + out Sprite sprite, + InputGlyphDatabase db = null) + { + sprite = null; + db ??= Database; + return db != null && db.TryGetSprite(controlPath, device, out sprite); + } + + public static string GetDisplayNameFromInputAction( + InputAction action, + string compositePartName = null, + InputDeviceWatcher.InputDeviceCategory? deviceOverride = null) + { + if (!TryGetBindingControl(action, compositePartName, deviceOverride, out InputBinding binding)) + { + return string.Empty; + } + + string display = binding.ToDisplayString(); + return string.IsNullOrEmpty(display) ? GetDisplayNameFromControlPath(GetEffectivePath(binding)) : display; + } + + public static string GetDisplayNameFromControlPath(string controlPath) + { + if (string.IsNullOrWhiteSpace(controlPath)) + { + return string.Empty; + } + + if (DisplayNameCache.TryGetValue(controlPath, out string cachedDisplayName)) + { + return cachedDisplayName; + } + + string humanReadable = InputControlPath.ToHumanReadableString(controlPath, InputControlPath.HumanReadableStringOptions.OmitDevice); + if (!string.IsNullOrWhiteSpace(humanReadable)) + { + DisplayNameCache[controlPath] = humanReadable; + return humanReadable; + } + + int separatorIndex = controlPath.LastIndexOf('/'); + string last = (separatorIndex >= 0 ? controlPath.Substring(separatorIndex + 1) : controlPath).Trim(TrimChars); + DisplayNameCache[controlPath] = last; + return last; + } + + public static bool TryGetBindingControl( + InputAction action, + string compositePartName, + InputDeviceWatcher.InputDeviceCategory? deviceOverride, + out InputBinding binding) + { + binding = default; + if (action == null) + { + return false; + } + + InputDeviceWatcher.InputDeviceCategory category = deviceOverride ?? InputDeviceWatcher.CurrentCategory; + int bestScore = int.MinValue; + bool requireCompositePart = !string.IsNullOrEmpty(compositePartName); + + for (int i = 0; i < action.bindings.Count; i++) + { + InputBinding candidate = action.bindings[i]; + if (candidate.isComposite) + { + continue; + } + + if (requireCompositePart) + { + if (!candidate.isPartOfComposite || !string.Equals(candidate.name, compositePartName, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + } + else if (candidate.isPartOfComposite) + { + continue; + } + + string path = GetEffectivePath(candidate); + if (string.IsNullOrWhiteSpace(path)) + { + continue; + } + + int score = ScoreBinding(candidate, category); + if (score > bestScore) + { + bestScore = score; + binding = candidate; + } + } + + return bestScore > int.MinValue; + } + + private static int ScoreBinding(InputBinding binding, InputDeviceWatcher.InputDeviceCategory category) + { + int score = 0; + string path = GetEffectivePath(binding); + + if (MatchesBindingGroups(binding.groups, category)) + { + score += 100; + } + else if (!string.IsNullOrWhiteSpace(binding.groups)) + { + score -= 20; + } + + if (MatchesControlPath(path, category)) + { + score += 60; + } + + if (!binding.isPartOfComposite) + { + score += 5; + } + + return score; + } + + private static bool MatchesBindingGroups(string groups, InputDeviceWatcher.InputDeviceCategory category) + { + if (string.IsNullOrWhiteSpace(groups)) + { + return false; + } + + string[] hints = GetGroupHints(category); + int tokenStart = 0; + for (int i = 0; i <= groups.Length; i++) + { + if (i < groups.Length && groups[i] != InputBinding.Separator) + { + continue; + } + + int tokenLength = i - tokenStart; + while (tokenLength > 0 && char.IsWhiteSpace(groups[tokenStart])) + { + tokenStart++; + tokenLength--; + } + + while (tokenLength > 0 && char.IsWhiteSpace(groups[tokenStart + tokenLength - 1])) + { + tokenLength--; + } + + if (tokenLength > 0) + { + string token = groups.Substring(tokenStart, tokenLength); + if (ContainsAny(token, hints)) + { + return true; + } + } + + tokenStart = i + 1; + } + + return false; + } + + private static string GetSpriteTag(Sprite sprite) + { + if (sprite == null) + { + return null; + } + + int instanceId = sprite.GetInstanceID(); + if (SpriteTagCache.TryGetValue(instanceId, out string cachedTag)) + { + return cachedTag; + } + + cachedTag = $""; + SpriteTagCache[instanceId] = cachedTag; + return cachedTag; + } + + private static bool ContainsAny(string source, string[] hints) + { + if (string.IsNullOrWhiteSpace(source) || hints == null) + { + return false; + } + + for (int i = 0; i < hints.Length; i++) + { + if (source.IndexOf(hints[i], StringComparison.OrdinalIgnoreCase) >= 0) + { + return true; + } + } + + return false; + } + + private static bool StartsWithDevice(string path, string deviceTag) + { + return path.StartsWith(deviceTag, StringComparison.OrdinalIgnoreCase); + } + + private static string[] GetGroupHints(InputDeviceWatcher.InputDeviceCategory category) + { + switch (category) + { + case InputDeviceWatcher.InputDeviceCategory.Keyboard: + return KeyboardGroupHints; + case InputDeviceWatcher.InputDeviceCategory.Xbox: + return XboxGroupHints; + case InputDeviceWatcher.InputDeviceCategory.PlayStation: + return PlayStationGroupHints; + default: + return OtherGamepadGroupHints; + } + } + + private static string GetEffectivePath(InputBinding binding) + { + return string.IsNullOrWhiteSpace(binding.effectivePath) ? binding.path : binding.effectivePath; + } + + private static bool MatchesControlPath(string path, InputDeviceWatcher.InputDeviceCategory category) + { + if (string.IsNullOrWhiteSpace(path)) + { + return false; + } + + switch (category) + { + case InputDeviceWatcher.InputDeviceCategory.Keyboard: + return StartsWithDevice(path, "") || StartsWithDevice(path, ""); + case InputDeviceWatcher.InputDeviceCategory.Xbox: + return StartsWithDevice(path, "") || StartsWithDevice(path, "") || ContainsAny(path, XboxGroupHints); + case InputDeviceWatcher.InputDeviceCategory.PlayStation: + return StartsWithDevice(path, "") || StartsWithDevice(path, "") || ContainsAny(path, PlayStationGroupHints); + default: + return StartsWithDevice(path, "") || StartsWithDevice(path, "") || ContainsAny(path, OtherGamepadGroupHints); + } + } + +} diff --git a/Runtime/InputGlyph/Core/GlyphService.cs.meta b/Runtime/InputGlyph/Core/GlyphService.cs.meta new file mode 100644 index 0000000..b942164 --- /dev/null +++ b/Runtime/InputGlyph/Core/GlyphService.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 1cc7ec0447a544ac984f3aac7a7b71d4 +timeCreated: 1764917633 \ No newline at end of file diff --git a/Runtime/InputGlyph/Core/InputActionReader.cs b/Runtime/InputGlyph/Core/InputActionReader.cs new file mode 100644 index 0000000..3c97638 --- /dev/null +++ b/Runtime/InputGlyph/Core/InputActionReader.cs @@ -0,0 +1,300 @@ +using System; +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.InputSystem; + +/// +/// 输入读取工具。 +/// 负责运行时输入轮询、单次触发和切换态管理, +/// +public static class InputActionReader +{ + /// + /// 用于标识一次输入读取上下文。 + /// 同一个 Action 在不同 owner 或 key 下会拥有独立的按下状态。 + /// + private readonly struct InputReadKey : IEquatable + { + public readonly string ActionName; + public readonly int OwnerId; + public readonly string OwnerKey; + + /// + /// 使用实例 ID 作为拥有者标识,适合 Unity 对象。 + /// + public InputReadKey(string actionName, int ownerId) + { + ActionName = actionName ?? string.Empty; + OwnerId = ownerId; + OwnerKey = string.Empty; + } + + /// + /// 使用字符串作为拥有者标识,适合外部系统或手动传入的 key。 + /// + public InputReadKey(string actionName, string ownerKey) + { + ActionName = actionName ?? string.Empty; + OwnerId = 0; + OwnerKey = ownerKey ?? string.Empty; + } + + public bool Equals(InputReadKey other) + { + return OwnerId == other.OwnerId + && string.Equals(ActionName, other.ActionName, StringComparison.Ordinal) + && string.Equals(OwnerKey, other.OwnerKey, StringComparison.Ordinal); + } + + public override bool Equals(object obj) + { + return obj is InputReadKey other && Equals(other); + } + + public override int GetHashCode() + { + unchecked + { + int hashCode = 17; + hashCode = (hashCode * 31) + OwnerId; + hashCode = (hashCode * 31) + StringComparer.Ordinal.GetHashCode(ActionName); + hashCode = (hashCode * 31) + StringComparer.Ordinal.GetHashCode(OwnerKey); + return hashCode; + } + } + } + + // 记录“本次按下已消费”的键,用于 Once 语义。 + private static readonly HashSet PressedKeys = new(); + // 记录当前处于开启状态的切换键。 + private static readonly HashSet ToggledKeys = new(); + + /// + /// 直接读取指定 Action 的值。 + /// + public static T ReadValue(string actionName) where T : struct + { + return ResolveAction(actionName).ReadValue(); + } + + /// + /// 以 object 形式读取指定 Action 的值。 + /// + public static object ReadValue(string actionName) + { + return ResolveAction(actionName).ReadValueAsObject(); + } + + /// + /// 仅在 Action 处于按下状态时读取值。 + /// + public static bool TryReadValue(string actionName, out T value) where T : struct + { + InputAction inputAction = ResolveAction(actionName); + if (inputAction.IsPressed()) + { + value = inputAction.ReadValue(); + return true; + } + + value = default; + return false; + } + + /// + /// 仅在 Action 处于按下状态时以 object 形式读取值。 + /// + public static bool TryReadValue(string actionName, out object value) + { + InputAction inputAction = ResolveAction(actionName); + if (inputAction.IsPressed()) + { + value = inputAction.ReadValueAsObject(); + return true; + } + + value = default; + return false; + } + + /// + /// 只在本次按下的第一帧返回 true,并输出当前值。 + /// owner 用来隔离不同对象的读取状态。 + /// + public static bool TryReadValueOnce(UnityEngine.Object owner, string actionName, out T value) where T : struct + { + if (owner == null) + { + value = default; + return false; + } + + return TryReadValueOnceInternal(new InputReadKey(actionName, owner.GetInstanceID()), actionName, out value); + } + + /// + /// 读取按钮型 Action。 + /// 非按钮类型会直接抛出异常,避免误用。 + /// + public static bool ReadButton(string actionName) + { + InputAction inputAction = ResolveAction(actionName); + if (inputAction.type == InputActionType.Button) + { + return Convert.ToBoolean(inputAction.ReadValueAsObject()); + } + + throw new NotSupportedException("[InputActionReader] The Input Action must be a button type."); + } + + /// + /// 对 Unity 对象做一次性按钮读取。 + /// + public static bool ReadButtonOnce(UnityEngine.Object owner, string actionName) + { + return owner != null && ReadButtonOnce(owner.GetInstanceID(), actionName); + } + + /// + /// 对实例 ID 做一次性按钮读取。 + /// + public static bool ReadButtonOnce(int instanceID, string actionName) + { + return ReadButtonOnceInternal(new InputReadKey(actionName, instanceID), actionName); + } + + /// + /// 对字符串 key 做一次性按钮读取。 + /// + public static bool ReadButtonOnce(string key, string actionName) + { + return ReadButtonOnceInternal(new InputReadKey(actionName, key), actionName); + } + + /// + /// 对 Unity 对象读取按钮切换态。 + /// 每次新的按下沿会在开/关之间切换。 + /// + public static bool ReadButtonToggle(UnityEngine.Object owner, string actionName) + { + return owner != null && ReadButtonToggle(owner.GetInstanceID(), actionName); + } + + /// + /// 对实例 ID 读取按钮切换态。 + /// + public static bool ReadButtonToggle(int instanceID, string actionName) + { + return ReadButtonToggleInternal(new InputReadKey(actionName, instanceID), actionName); + } + + /// + /// 对字符串 key 读取按钮切换态。 + /// + public static bool ReadButtonToggle(string key, string actionName) + { + return ReadButtonToggleInternal(new InputReadKey(actionName, key), actionName); + } + + /// + /// 重置指定 key 的切换态。 + /// + public static void ResetToggledButton(string key, string actionName) + { + ToggledKeys.Remove(new InputReadKey(actionName, key)); + } + + /// + /// 重置某个 Action 名称下的所有切换态。 + /// + public static void ResetToggledButton(string actionName) + { + if (string.IsNullOrEmpty(actionName) || ToggledKeys.Count == 0) + { + return; + } + + InputReadKey[] snapshot = new InputReadKey[ToggledKeys.Count]; + ToggledKeys.CopyTo(snapshot); + for (int i = 0; i < snapshot.Length; i++) + { + if (string.Equals(snapshot[i].ActionName, actionName, StringComparison.Ordinal)) + { + ToggledKeys.Remove(snapshot[i]); + } + } + } + + /// + /// 清空全部切换态缓存。 + /// + public static void ResetToggledButtons() + { + ToggledKeys.Clear(); + } + + /// + /// 解析 Action;找不到时立即抛错,避免静默失败。 + /// + private static InputAction ResolveAction(string actionName) + { + return InputBindingManager.Action(actionName) + ?? throw new InvalidOperationException($"[InputActionReader] Action '{actionName}' is not available."); + } + + /// + /// 内部的单次值读取逻辑。 + /// 当按键抬起时,会清理 PressedKeys 中对应状态。 + /// + private static bool TryReadValueOnceInternal(InputReadKey readKey, string actionName, out T value) where T : struct + { + InputAction inputAction = ResolveAction(actionName); + if (inputAction.IsPressed()) + { + if (PressedKeys.Add(readKey)) + { + value = inputAction.ReadValue(); + return true; + } + } + else + { + PressedKeys.Remove(readKey); + } + + value = default; + return false; + } + + /// + /// 内部的按钮单次触发逻辑。 + /// 只有第一次按下返回 true,持续按住不会重复触发。 + /// + private static bool ReadButtonOnceInternal(InputReadKey readKey, string actionName) + { + if (ReadButton(actionName)) + { + return PressedKeys.Add(readKey); + } + + PressedKeys.Remove(readKey); + return false; + } + + /// + /// 内部的按钮切换逻辑。 + /// 基于 Once 触发,在每次新的按下沿时切换状态。 + /// + private static bool ReadButtonToggleInternal(InputReadKey readKey, string actionName) + { + if (ReadButtonOnceInternal(readKey, actionName)) + { + if (!ToggledKeys.Add(readKey)) + { + ToggledKeys.Remove(readKey); + } + } + + return ToggledKeys.Contains(readKey); + } +} diff --git a/Runtime/InputGlyph/Core/InputActionReader.cs.meta b/Runtime/InputGlyph/Core/InputActionReader.cs.meta new file mode 100644 index 0000000..3dddf32 --- /dev/null +++ b/Runtime/InputGlyph/Core/InputActionReader.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: b9368556ed4729ae618e0a19d3a7925b +timeCreated: 1773811724 diff --git a/Runtime/InputGlyph/Core/InputBindingManager.cs b/Runtime/InputGlyph/Core/InputBindingManager.cs new file mode 100644 index 0000000..a9ced19 --- /dev/null +++ b/Runtime/InputGlyph/Core/InputBindingManager.cs @@ -0,0 +1,844 @@ +using System; +using System.IO; +using System.Collections.Generic; +using System.Threading.Tasks; +using UnityEngine; +using UnityEngine.InputSystem; +using AlicizaX; + +public class InputBindingManager : MonoSingleton +{ + public const string NULL_BINDING = "__NULL__"; + private const string KEYBOARD_DEVICE = ""; + private const string MOUSE_DELTA = "/delta"; + private const string MOUSE_SCROLL = "/scroll"; + private const string MOUSE_SCROLL_X = "/scroll/x"; + private const string MOUSE_SCROLL_Y = "/scroll/y"; + private const string KEYBOARD_ESCAPE = "/escape"; + + [Tooltip("InputActionAsset to manage")] + public InputActionAsset actions; + + private const string FILE_NAME = "input_bindings.json"; + public bool debugMode = false; + + internal InputActionRebindingExtensions.RebindingOperation rebindOperation; + private bool isApplyPending = false; + private string defaultBindingsJson = string.Empty; + private string cachedSavePath; + private readonly Dictionary actionMap = new(StringComparer.Ordinal); + private readonly HashSet preparedRebinds = new(); + private readonly Dictionary actionLookup = new(StringComparer.Ordinal); + private readonly Dictionary actionLookupById = new(); + private readonly HashSet ambiguousActionNames = new(StringComparer.Ordinal); + private event Action _onInputsInit; + + public event Action OnInputsInit + { + add + { + _onInputsInit += value; + // 重放行为:如果已经初始化,立即调用 + if (isInputsInitialized) + { + value?.Invoke(); + } + } + remove { _onInputsInit -= value; } + } + + public event Action> OnApply; + public event Action OnRebindPrepare; + public event Action OnRebindStart; + public event Action OnRebindEnd; + public event Action OnRebindConflict; + public static event Action BindingsChanged; + + private bool isInputsInitialized = false; + + public IReadOnlyDictionary ActionMaps => actionMap; + public IReadOnlyCollection PreparedRebinds => preparedRebinds; + + public string SavePath + { + get + { + if (!string.IsNullOrEmpty(cachedSavePath)) + return cachedSavePath; + +#if UNITY_EDITOR + string folder = Application.dataPath; +#else + string folder = Application.persistentDataPath; +#endif + cachedSavePath = Path.Combine(folder, FILE_NAME); + return cachedSavePath; + } + } + + private void EnsureSaveDirectoryExists() + { + var directory = Path.GetDirectoryName(SavePath); + if (!Directory.Exists(directory)) + Directory.CreateDirectory(directory); + } + + protected override void Awake() + { + if (_instance != null && _instance != this) + { + Destroy(gameObject); + return; + } + + _instance = this; + + if (actions == null) + { + Log.Error("InputBindingManager: InputActionAsset not assigned."); + return; + } + + BuildActionMap(); + + try + { + defaultBindingsJson = actions.SaveBindingOverridesAsJson(); + } + catch (Exception ex) + { + Log.Warning($"[InputBindingManager] Failed to save default bindings: {ex.Message}"); + defaultBindingsJson = string.Empty; + } + + if (File.Exists(SavePath)) + { + try + { + var json = File.ReadAllText(SavePath); + if (!string.IsNullOrEmpty(json)) + { + actions.LoadBindingOverridesFromJson(json); + RefreshBindingPathsFromActions(); + BindingsChanged?.Invoke(); + if (debugMode) + { + Log.Info($"Loaded overrides from {SavePath}"); + } + } + } + catch (Exception ex) + { + Log.Error("Failed to load overrides: " + ex); + } + } + + isInputsInitialized = true; + _onInputsInit?.Invoke(); + actions.Enable(); + } + + protected override void OnDestroy() + { + if (_instance == this) + { + _instance = null; + } + + rebindOperation?.Dispose(); + rebindOperation = null; + + // 清除所有事件处理器 + _onInputsInit = null; + OnApply = null; + OnRebindPrepare = null; + OnRebindStart = null; + OnRebindEnd = null; + OnRebindConflict = null; + BindingsChanged = null; + } + + private void BuildActionMap() + { + actionMap.Clear(); + actionLookup.Clear(); + actionLookupById.Clear(); + ambiguousActionNames.Clear(); + + foreach (var map in actions.actionMaps) + { + var actionMapObj = new ActionMap(map); + actionMap.Add(map.name, actionMapObj); + + foreach (var actionPair in actionMapObj.actions) + { + RegisterActionLookup(map.name, actionPair.Key, actionMapObj, actionPair.Value); + } + } + } + + private void RegisterActionLookup(string mapName, string actionName, ActionMap map, ActionMap.Action action) + { + actionLookupById[action.action.id] = (map, action); + actionLookup[$"{mapName}/{actionName}"] = (map, action); + + if (ambiguousActionNames.Contains(actionName)) + { + return; + } + + if (actionLookup.TryGetValue(actionName, out var existing)) + { + if (existing.action.action != action.action) + { + actionLookup.Remove(actionName); + ambiguousActionNames.Add(actionName); + Log.Warning($"[InputBindingManager] Duplicate action name '{actionName}' detected. Use 'MapName/{actionName}' to resolve it."); + } + + return; + } + + actionLookup[actionName] = (map, action); + } + + private void RefreshBindingPathsFromActions() + { + foreach (var mapPair in actionMap.Values) + { + foreach (var actionPair in mapPair.actions.Values) + { + var a = actionPair; + foreach (var bpair in a.bindings) + { + bpair.Value.bindingPath.EffectivePath = a.action.bindings[bpair.Key].effectivePath; + } + } + } + } + + public sealed class ActionMap + { + public string name; + public Dictionary actions; + + public ActionMap(InputActionMap map) + { + name = map.name; + int actionCount = map.actions.Count; + actions = new Dictionary(actionCount); + foreach (var action in map.actions) + { + actions.Add(action.name, new Action(action)); + } + } + + public sealed class Action + { + public InputAction action; + public Dictionary bindings; + + public Action(InputAction action) + { + this.action = action; + int count = action.bindings.Count; + bindings = new Dictionary(count); + + for (int i = 0; i < count; i++) + { + if (action.bindings[i].isComposite) + { + int first = i + 1; + int last = first; + while (last < count && action.bindings[last].isPartOfComposite) last++; + for (int p = first; p < last; p++) + AddBinding(action.bindings[p], p); + i = last - 1; + } + else + { + AddBinding(action.bindings[i], i); + } + } + + void AddBinding(InputBinding binding, int bindingIndex) + { + bindings.Add(bindingIndex, new Binding( + binding.name, + action.name, + binding.name, + bindingIndex, + new BindingPath(binding.path, binding.overridePath), + binding + )); + } + } + + public readonly struct Binding + { + public readonly string name; + public readonly string parentAction; + public readonly string compositePart; + public readonly int bindingIndex; + public readonly BindingPath bindingPath; + public readonly InputBinding inputBinding; + + public Binding(string name, string parentAction, string compositePart, int bindingIndex, + BindingPath bindingPath, InputBinding inputBinding) + { + this.name = name; + this.parentAction = parentAction; + this.compositePart = compositePart; + this.bindingIndex = bindingIndex; + this.bindingPath = bindingPath; + this.inputBinding = inputBinding; + } + } + } + } + + public sealed class BindingPath + { + public string bindingPath; + public string overridePath; + private event Action onEffectivePathChanged; + + public BindingPath(string bindingPath, string overridePath) + { + this.bindingPath = bindingPath; + this.overridePath = overridePath; + } + + public string EffectivePath + { + get => !string.IsNullOrEmpty(overridePath) ? overridePath : bindingPath; + set + { + overridePath = (value == bindingPath) ? string.Empty : value; + onEffectivePathChanged?.Invoke(EffectivePath); + } + } + + public void SubscribeToEffectivePathChanged(Action callback) + { + onEffectivePathChanged += callback; + } + + public void UnsubscribeFromEffectivePathChanged(Action callback) + { + onEffectivePathChanged -= callback; + } + + public void Dispose() + { + onEffectivePathChanged = null; + } + } + + public sealed class RebindContext + { + public InputAction action; + public int bindingIndex; + public string overridePath; + private string cachedToString; + + public RebindContext(InputAction action, int bindingIndex, string overridePath) + { + this.action = action; + this.bindingIndex = bindingIndex; + this.overridePath = overridePath; + } + + public override bool Equals(object obj) + { + if (obj is not RebindContext other) return false; + if (action == null || other.action == null) return false; + return action.id == other.action.id && bindingIndex == other.bindingIndex; + } + + public override int GetHashCode() + { + unchecked + { + int hashCode = 17; + hashCode = (hashCode * 31) + (action != null ? action.id.GetHashCode() : 0); + hashCode = (hashCode * 31) + bindingIndex; + return hashCode; + } + } + + public override string ToString() + { + if (cachedToString == null && action != null) + { + string mapName = action.actionMap != null ? action.actionMap.name : ""; + cachedToString = $"{mapName}/{action.name}:{bindingIndex}"; + } + + return cachedToString ?? ""; + } + } + + /* ---------------- Public API ---------------- */ + + /// + /// 根据操作名称获取输入操作 + /// + /// 操作名称 + /// 输入操作,未找到则返回 null + public static InputAction Action(string actionName) + { + var instance = Instance; + if (instance == null) return null; + + if (TryGetAction(actionName, out InputAction action)) + { + return action; + } + + if (instance.ambiguousActionNames.Contains(actionName)) + { + Log.Error($"[InputBindingManager] Action name '{actionName}' is ambiguous. Use 'MapName/{actionName}' instead."); + return null; + } + + Log.Error($"[InputBindingManager] Could not find action '{actionName}'"); + return null; + } + + public static bool TryGetAction(string actionName, out InputAction action) + { + var instance = Instance; + if (instance == null || string.IsNullOrWhiteSpace(actionName)) + { + action = null; + return false; + } + + if (instance.actionLookup.TryGetValue(actionName, out var result)) + { + action = result.action.action; + return true; + } + + action = null; + return false; + } + + /// + /// 开始重新绑定指定的输入操作 + /// + /// 操作名称 + /// 复合部分名称(可选) + public static void StartRebind(string actionName, string compositePartName = null) + { + var action = Action(actionName); + if (action == null) return; + + // 自动决定 bindingIndex 和 deviceMatch + int bindingIndex = Instance.FindBestBindingIndexForKeyboard(action, compositePartName); + if (bindingIndex < 0) + { + Log.Error($"[InputBindingManager] No suitable binding found for action '{actionName}' (part={compositePartName ?? ""})"); + return; + } + + Instance.actions.Disable(); + Instance.PerformInteractiveRebinding(action, bindingIndex, KEYBOARD_DEVICE, true); + Instance.OnRebindStart?.Invoke(); + if (Instance.debugMode) + { + Log.Info("[InputBindingManager] Rebind started"); + } + } + + /// + /// 取消当前的重新绑定操作 + /// + public static void CancelRebind() => Instance.rebindOperation?.Cancel(); + + /// + /// 确认并应用准备好的重新绑定 + /// + /// 是否清除冲突 + /// 是否成功应用 + public static async Task ConfirmApply(bool clearConflicts = true) + { + if (!Instance.isApplyPending) return false; + + try + { + // 在清除之前创建准备好的重绑定的副本 + HashSet appliedContexts = Instance.OnApply != null + ? new HashSet(Instance.preparedRebinds) + : null; + + foreach (var ctx in Instance.preparedRebinds) + { + if (!string.IsNullOrEmpty(ctx.overridePath)) + { + if (ctx.overridePath == NULL_BINDING) + { + ctx.action.RemoveBindingOverride(ctx.bindingIndex); + } + else + { + ctx.action.ApplyBindingOverride(ctx.bindingIndex, ctx.overridePath); + } + } + + var bp = GetBindingPath(ctx.action, ctx.bindingIndex); + if (bp != null) + { + bp.EffectivePath = (ctx.overridePath == NULL_BINDING) ? string.Empty : ctx.overridePath; + } + } + + Instance.preparedRebinds.Clear(); + await Instance.WriteOverridesToDiskAsync(); + BindingsChanged?.Invoke(); + Instance.OnApply?.Invoke(true, appliedContexts); + Instance.isApplyPending = false; + if (Instance.debugMode) + { + Log.Info("[InputBindingManager] Apply confirmed and saved."); + } + + return true; + } + catch (Exception ex) + { + Log.Error("[InputBindingManager] Failed to apply binds: " + ex); + Instance.OnApply?.Invoke(false, null); + return false; + } + } + + /// + /// 丢弃准备好的重新绑定 + /// + public static void DiscardPrepared() + { + if (!Instance.isApplyPending) return; + + // 在清除之前创建准备好的重绑定的副本(用于事件通知) + HashSet discardedContexts = Instance.OnApply != null + ? new HashSet(Instance.preparedRebinds) + : null; + + Instance.preparedRebinds.Clear(); + Instance.isApplyPending = false; + Instance.OnApply?.Invoke(false, discardedContexts); + if (Instance.debugMode) + { + Log.Info("[InputBindingManager] Prepared rebinds discarded."); + } + } + + private void PerformInteractiveRebinding(InputAction action, int bindingIndex, string deviceMatchPath = null, bool excludeMouseMovementAndScroll = true) + { + var op = action.PerformInteractiveRebinding(bindingIndex); + + if (!string.IsNullOrEmpty(deviceMatchPath)) + { + op = op.WithControlsHavingToMatchPath(deviceMatchPath); + } + + if (excludeMouseMovementAndScroll) + { + op = op.WithControlsExcluding(MOUSE_DELTA) + .WithControlsExcluding(MOUSE_SCROLL) + .WithControlsExcluding(MOUSE_SCROLL_X) + .WithControlsExcluding(MOUSE_SCROLL_Y); + } + + rebindOperation = op + .OnApplyBinding((o, path) => + { + RebindContext preparedContext = new RebindContext(action, bindingIndex, path); + if (AnyPreparedRebind(path, action, bindingIndex, out var existing)) + { + PrepareRebind(preparedContext); + PrepareRebind(new RebindContext(existing.action, existing.bindingIndex, NULL_BINDING)); + OnRebindConflict?.Invoke(preparedContext, existing); + } + else if (AnyBindingPath(path, action, bindingIndex, out var dup)) + { + RebindContext conflictingContext = new RebindContext(dup.action, dup.bindingIndex, dup.action.bindings[dup.bindingIndex].path); + PrepareRebind(preparedContext); + PrepareRebind(new RebindContext(dup.action, dup.bindingIndex, NULL_BINDING)); + OnRebindConflict?.Invoke(preparedContext, conflictingContext); + } + else + { + PrepareRebind(preparedContext); + } + }) + .OnComplete(opc => + { + if (debugMode) + { + Log.Info("[InputBindingManager] Rebind completed"); + } + + actions.Enable(); + OnRebindEnd?.Invoke(true, new RebindContext(action, bindingIndex, action.bindings[bindingIndex].effectivePath)); + CleanRebindOperation(); + }) + .OnCancel(opc => + { + if (debugMode) + { + Log.Info("[InputBindingManager] Rebind cancelled"); + } + + actions.Enable(); + OnRebindEnd?.Invoke(false, new RebindContext(action, bindingIndex, action.bindings[bindingIndex].effectivePath)); + CleanRebindOperation(); + }) + .WithCancelingThrough(KEYBOARD_ESCAPE) + .Start(); + } + + private void CleanRebindOperation() + { + rebindOperation?.Dispose(); + rebindOperation = null; + } + + private bool AnyPreparedRebind(string bindingPath, InputAction currentAction, int currentIndex, out RebindContext duplicate) + { + foreach (var ctx in preparedRebinds) + { + if (ctx.overridePath == bindingPath && (ctx.action != currentAction || (ctx.action == currentAction && ctx.bindingIndex != currentIndex))) + { + duplicate = ctx; + return true; + } + } + + duplicate = null; + return false; + } + + private bool AnyBindingPath(string bindingPath, InputAction currentAction, int currentIndex, out (InputAction action, int bindingIndex) duplicate) + { + foreach (var map in actionMap.Values) + { + foreach (var actionPair in map.actions.Values) + { + bool isSameAction = actionPair.action == currentAction; + + foreach (var bindingPair in actionPair.bindings) + { + // Skip if it's the same action and same binding index + if (isSameAction && bindingPair.Key == currentIndex) + continue; + + if (bindingPair.Value.bindingPath.EffectivePath == bindingPath) + { + duplicate = (actionPair.action, bindingPair.Key); + return true; + } + } + } + } + + duplicate = default; + return false; + } + + private void PrepareRebind(RebindContext context) + { + // Remove any existing prepared state for the same action/binding pair. + preparedRebinds.Remove(context); + + BindingPath bindingPath = GetBindingPath(context.action, context.bindingIndex); + if (bindingPath == null) return; + + if (string.IsNullOrEmpty(context.overridePath)) + { + context.overridePath = bindingPath.bindingPath; + } + + if (bindingPath.EffectivePath != context.overridePath) + { + preparedRebinds.Add(context); + isApplyPending = true; + OnRebindPrepare?.Invoke(context); + if (debugMode) + { + Log.Info($"Prepared rebind: {context} -> {context.overridePath}"); + } + } + } + + private async Task WriteOverridesToDiskAsync() + { + try + { + var json = actions.SaveBindingOverridesAsJson(); + EnsureSaveDirectoryExists(); + using (var sw = new StreamWriter(SavePath, false)) await sw.WriteAsync(json); + if (debugMode) + { + Log.Info($"Overrides saved to {SavePath}"); + } + } + catch (Exception ex) + { + Log.Error("Failed to save overrides: " + ex); + throw; + } + } + + /// + /// 重置所有绑定到默认值 + /// + public async Task ResetToDefaultAsync() + { + try + { + if (!string.IsNullOrEmpty(defaultBindingsJson)) + { + actions.LoadBindingOverridesFromJson(defaultBindingsJson); + } + else + { + foreach (var map in actionMap.Values) + { + foreach (var a in map.actions.Values) + { + for (int b = 0; b < a.action.bindings.Count; b++) + { + a.action.RemoveBindingOverride(b); + } + } + } + } + + RefreshBindingPathsFromActions(); + await WriteOverridesToDiskAsync(); + BindingsChanged?.Invoke(); + if (debugMode) + { + Log.Info("Reset to default and saved."); + } + } + catch (Exception ex) + { + Log.Error("Failed to reset defaults: " + ex); + } + } + + /// + /// 获取指定操作的绑定路径 + /// + /// 操作名称 + /// 绑定索引 + /// 绑定路径,未找到则返回 null + public static BindingPath GetBindingPath(string actionName, int bindingIndex = 0) + { + var instance = Instance; + if (instance == null) return null; + + if (instance.TryGetActionRecord(actionName, out var result) + && result.action.bindings.TryGetValue(bindingIndex, out var binding)) + { + return binding.bindingPath; + } + + return null; + } + + public static BindingPath GetBindingPath(InputAction action, int bindingIndex = 0) + { + var instance = Instance; + if (instance == null || action == null) return null; + + if (instance.TryGetActionRecord(action, out var result) + && result.action.bindings.TryGetValue(bindingIndex, out var binding)) + { + return binding.bindingPath; + } + + return null; + } + + private bool TryGetActionRecord(string actionName, out (ActionMap map, ActionMap.Action action) result) + { + return actionLookup.TryGetValue(actionName, out result); + } + + private bool TryGetActionRecord(InputAction action, out (ActionMap map, ActionMap.Action action) result) + { + if (action != null && actionLookupById.TryGetValue(action.id, out result)) + { + return result.action.action == action; + } + + result = default; + return false; + } + + + // 为键盘选择最佳绑定索引;如果 compositePartName != null 则查找部分 + /// + /// 为键盘查找最佳的绑定索引 + /// + /// 输入操作 + /// 复合部分名称(可选) + /// 绑定索引,未找到则返回 -1 + public int FindBestBindingIndexForKeyboard(InputAction action, string compositePartName = null) + { + if (action == null) return -1; + + int fallbackPart = -1; + int fallbackNonComposite = -1; + bool searchingForCompositePart = !string.IsNullOrEmpty(compositePartName); + + for (int i = 0; i < action.bindings.Count; i++) + { + var b = action.bindings[i]; + + // 如果搜索特定的复合部分,跳过不匹配的绑定 + if (searchingForCompositePart) + { + if (!b.isPartOfComposite) continue; + if (!string.Equals(b.name, compositePartName, StringComparison.OrdinalIgnoreCase)) continue; + } + + // 检查此绑定是否用于键盘 + bool isKeyboardBinding = (!string.IsNullOrEmpty(b.path) && b.path.StartsWith(KEYBOARD_DEVICE, StringComparison.OrdinalIgnoreCase)) || + (!string.IsNullOrEmpty(b.effectivePath) && b.effectivePath.StartsWith(KEYBOARD_DEVICE, StringComparison.OrdinalIgnoreCase)); + + if (b.isPartOfComposite) + { + if (fallbackPart == -1) fallbackPart = i; + if (isKeyboardBinding) return i; + } + else + { + if (fallbackNonComposite == -1) fallbackNonComposite = i; + if (isKeyboardBinding) return i; + } + } + + return fallbackNonComposite >= 0 ? fallbackNonComposite : fallbackPart; + } + + public static InputBindingManager Instance + { + get + { + if (_instance == null) + { + _instance = FindObjectOfType(); + } + + return _instance; + } + } + + private static InputBindingManager _instance; +} diff --git a/Runtime/InputGlyph/Core/InputBindingManager.cs.meta b/Runtime/InputGlyph/Core/InputBindingManager.cs.meta new file mode 100644 index 0000000..fa50fb0 --- /dev/null +++ b/Runtime/InputGlyph/Core/InputBindingManager.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 7c25ae9d04ef4e03a723135aa298de16 +timeCreated: 1765271070 \ No newline at end of file diff --git a/Runtime/InputGlyph/Core/InputDeviceWatcher.cs b/Runtime/InputGlyph/Core/InputDeviceWatcher.cs new file mode 100644 index 0000000..0fc4f71 --- /dev/null +++ b/Runtime/InputGlyph/Core/InputDeviceWatcher.cs @@ -0,0 +1,476 @@ +using System; +using System.Collections.Generic; +#if UNITY_EDITOR +using UnityEditor; +#endif +using UnityEngine; +using UnityEngine.InputSystem; +using UnityEngine.InputSystem.Controls; + +public static class InputDeviceWatcher +{ + public enum InputDeviceCategory + { + Keyboard, + Xbox, + PlayStation, + Other + } + + public readonly struct DeviceContext : IEquatable + { + public readonly InputDeviceCategory Category; + public readonly int DeviceId; + public readonly int VendorId; + public readonly int ProductId; + public readonly string DeviceName; + public readonly string Layout; + + public DeviceContext( + InputDeviceCategory category, + int deviceId, + int vendorId, + int productId, + string deviceName, + string layout) + { + Category = category; + DeviceId = deviceId; + VendorId = vendorId; + ProductId = productId; + DeviceName = deviceName ?? string.Empty; + Layout = layout ?? string.Empty; + } + + public bool Equals(DeviceContext other) + { + return Category == other.Category + && DeviceId == other.DeviceId + && VendorId == other.VendorId + && ProductId == other.ProductId + && string.Equals(DeviceName, other.DeviceName, StringComparison.Ordinal) + && string.Equals(Layout, other.Layout, StringComparison.Ordinal); + } + + public override bool Equals(object obj) + { + return obj is DeviceContext other && Equals(other); + } + + public override int GetHashCode() + { + unchecked + { + int hashCode = (int)Category; + hashCode = (hashCode * 397) ^ DeviceId; + hashCode = (hashCode * 397) ^ VendorId; + hashCode = (hashCode * 397) ^ ProductId; + hashCode = (hashCode * 397) ^ (DeviceName != null ? DeviceName.GetHashCode() : 0); + hashCode = (hashCode * 397) ^ (Layout != null ? Layout.GetHashCode() : 0); + return hashCode; + } + } + } + + [Serializable] + private struct DeviceCapabilityInfo + { + public int vendorId; + public int productId; + } + + private const float SameCategoryDebounceWindow = 0.15f; + private const float AxisActivationThreshold = 0.5f; + private const float StickActivationThreshold = 0.25f; + private const string DefaultKeyboardDeviceName = "Keyboard&Mouse"; + + public static InputDeviceCategory CurrentCategory { get; private set; } = InputDeviceCategory.Keyboard; + public static string CurrentDeviceName { get; private set; } = DefaultKeyboardDeviceName; + public static int CurrentDeviceId { get; private set; } = -1; + public static int CurrentVendorId { get; private set; } + public static int CurrentProductId { get; private set; } + public static DeviceContext CurrentContext { get; private set; } = CreateDefaultContext(); + + private static InputAction _anyInputAction; + private static float _lastSwitchTime = -Mathf.Infinity; + private static DeviceContext _lastEmittedContext = CreateDefaultContext(); + private static readonly Dictionary DeviceContextCache = new(); + private static bool _initialized; + + public static event Action OnDeviceChanged; + public static event Action OnDeviceContextChanged; + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] + public static void Initialize() + { + if (_initialized) + { + return; + } + + _initialized = true; + ApplyContext(CreateDefaultContext(), false); + _lastEmittedContext = CurrentContext; + + _anyInputAction = new InputAction("AnyDevice", InputActionType.PassThrough); + _anyInputAction.AddBinding("/anyKey"); + //为防止误触 暂时屏蔽鼠标检测 + // _anyInputAction.AddBinding("/delta"); + // _anyInputAction.AddBinding("/leftButton"); + // _anyInputAction.AddBinding("/rightButton"); + // _anyInputAction.AddBinding("/middleButton"); + // _anyInputAction.AddBinding("/scroll"); + _anyInputAction.AddBinding("/*"); + _anyInputAction.AddBinding("/*"); + _anyInputAction.performed += OnAnyInputPerformed; + _anyInputAction.Enable(); + + InputSystem.onDeviceChange += OnDeviceChange; +#if UNITY_EDITOR + EditorApplication.playModeStateChanged += OnPlayModeStateChanged; +#endif + } + +#if UNITY_EDITOR + private static void OnPlayModeStateChanged(PlayModeStateChange state) + { + if (state == PlayModeStateChange.ExitingPlayMode) + { + Dispose(); + EditorApplication.playModeStateChanged -= OnPlayModeStateChanged; + } + } +#endif + + public static void Dispose() + { + if (!_initialized) + { + return; + } + + if (_anyInputAction != null) + { + _anyInputAction.performed -= OnAnyInputPerformed; + _anyInputAction.Disable(); + _anyInputAction.Dispose(); + _anyInputAction = null; + } + + InputSystem.onDeviceChange -= OnDeviceChange; + DeviceContextCache.Clear(); + + ApplyContext(CreateDefaultContext(), false); + _lastEmittedContext = CurrentContext; + _lastSwitchTime = -Mathf.Infinity; + OnDeviceChanged = null; + OnDeviceContextChanged = null; + _initialized = false; + } + + private static void OnAnyInputPerformed(InputAction.CallbackContext context) + { + InputControl control = context.control; + if (!IsRelevantControl(control)) + { + return; + } + + InputDevice device = control.device; + if (device == null || device.deviceId == CurrentDeviceId) + { + return; + } + + DeviceContext deviceContext = BuildContext(device); + if (deviceContext.DeviceId == CurrentDeviceId) + { + return; + } + + float now = Time.realtimeSinceStartup; + if (deviceContext.Category == CurrentCategory && now - _lastSwitchTime < SameCategoryDebounceWindow) + { + return; + } + + _lastSwitchTime = now; + SetCurrentContext(deviceContext); + } + + private static void OnDeviceChange(InputDevice device, InputDeviceChange change) + { + if (device == null) + { + return; + } + + switch (change) + { + case InputDeviceChange.Removed: + case InputDeviceChange.Disconnected: + DeviceContextCache.Remove(device.deviceId); + if (device.deviceId == CurrentDeviceId) + { + PromoteFallbackDevice(device.deviceId); + } + + break; + case InputDeviceChange.Reconnected: + case InputDeviceChange.Added: + DeviceContextCache.Remove(device.deviceId); + if (CurrentDeviceId < 0 && IsRelevantDevice(device)) + { + SetCurrentContext(BuildContext(device)); + } + + break; + } + } + + private static void PromoteFallbackDevice(int removedDeviceId) + { + for (int i = InputSystem.devices.Count - 1; i >= 0; i--) + { + InputDevice device = InputSystem.devices[i]; + if (device == null || device.deviceId == removedDeviceId || !device.added || !IsRelevantDevice(device)) + { + continue; + } + + SetCurrentContext(BuildContext(device)); + return; + } + + SetCurrentContext(CreateDefaultContext()); + } + + private static void SetCurrentContext(DeviceContext context) + { + bool categoryChanged = CurrentCategory != context.Category; + ApplyContext(context, true); + + if (!_lastEmittedContext.Equals(context)) + { + OnDeviceContextChanged?.Invoke(context); + if (categoryChanged) + { + OnDeviceChanged?.Invoke(context.Category); + } + + _lastEmittedContext = context; + } + } + + private static void ApplyContext(DeviceContext context, bool log) + { + CurrentContext = context; + CurrentCategory = context.Category; + CurrentDeviceId = context.DeviceId; + CurrentVendorId = context.VendorId; + CurrentProductId = context.ProductId; + CurrentDeviceName = context.DeviceName; + +#if UNITY_EDITOR + if (log) + { + AlicizaX.Log.Info($"Input device -> {CurrentCategory} name={CurrentDeviceName} vid=0x{CurrentVendorId:X} pid=0x{CurrentProductId:X} id={CurrentDeviceId}"); + } +#endif + } + + private static DeviceContext BuildContext(InputDevice device) + { + if (device == null) + { + return CreateDefaultContext(); + } + + if (DeviceContextCache.TryGetValue(device.deviceId, out DeviceContext cachedContext)) + { + return cachedContext; + } + + TryParseVendorProductIds(device.description.capabilities, out int vendorId, out int productId); + string deviceName = string.IsNullOrWhiteSpace(device.displayName) ? device.name : device.displayName; + DeviceContext context = new DeviceContext( + DetermineCategoryFromDevice(device, vendorId), + device.deviceId, + vendorId, + productId, + deviceName, + device.layout); + DeviceContextCache[device.deviceId] = context; + return context; + } + + private static DeviceContext CreateDefaultContext() + { + return new DeviceContext(InputDeviceCategory.Keyboard, -1, 0, 0, DefaultKeyboardDeviceName, Keyboard.current != null ? Keyboard.current.layout : string.Empty); + } + + private static InputDeviceCategory DetermineCategoryFromDevice(InputDevice device, int vendorId = 0) + { + if (device == null) + { + return InputDeviceCategory.Keyboard; + } + + if (device is Keyboard || device is Mouse) + { + return InputDeviceCategory.Keyboard; + } + + if (IsGamepadLike(device)) + { + return GetGamepadCategory(device, vendorId); + } + + if (DescriptionContains(device, "xbox") || DescriptionContains(device, "xinput")) + { + return InputDeviceCategory.Xbox; + } + + if (DescriptionContains(device, "dualshock") + || DescriptionContains(device, "dualsense") + || DescriptionContains(device, "playstation")) + { + return InputDeviceCategory.PlayStation; + } + + return InputDeviceCategory.Other; + } + + private static bool IsRelevantDevice(InputDevice device) + { + return device is Keyboard || device is Mouse || IsGamepadLike(device); + } + + private static bool IsRelevantControl(InputControl control) + { + if (control == null || control.device == null || !IsRelevantDevice(control.device) || control.synthetic) + { + return false; + } + + switch (control) + { + case ButtonControl button: + return button.IsPressed(); + case StickControl stick: + return stick.ReadValue().sqrMagnitude >= StickActivationThreshold; + case Vector2Control vector2: + return vector2.ReadValue().sqrMagnitude >= StickActivationThreshold; + case AxisControl axis: + return Mathf.Abs(axis.ReadValue()) >= AxisActivationThreshold; + default: + return !control.noisy; + } + } + + private static bool IsGamepadLike(InputDevice device) + { + if (device is Gamepad || device is Joystick) + { + return true; + } + + string layout = device.layout ?? string.Empty; + if (ContainsIgnoreCase(layout, "Mouse") + || ContainsIgnoreCase(layout, "Touch") + || ContainsIgnoreCase(layout, "Pen")) + { + return false; + } + + return ContainsIgnoreCase(layout, "Gamepad") + || ContainsIgnoreCase(layout, "Controller") + || ContainsIgnoreCase(layout, "Joystick"); + } + + private static InputDeviceCategory GetGamepadCategory(InputDevice device, int vendorId = 0) + { + if (device == null) + { + return InputDeviceCategory.Other; + } + + string interfaceName = device.description.interfaceName ?? string.Empty; + if (ContainsIgnoreCase(interfaceName, "xinput")) + { + return InputDeviceCategory.Xbox; + } + + if (vendorId == 0 && TryParseVendorProductIds(device.description.capabilities, out int parsedVendorId, out _)) + { + vendorId = parsedVendorId; + } + + if (vendorId == 0x045E || vendorId == 1118) + { + return InputDeviceCategory.Xbox; + } + + if (vendorId == 0x054C || vendorId == 1356) + { + return InputDeviceCategory.PlayStation; + } + + if (DescriptionContains(device, "xbox")) + { + return InputDeviceCategory.Xbox; + } + + if (DescriptionContains(device, "dualshock") + || DescriptionContains(device, "dualsense") + || DescriptionContains(device, "playstation")) + { + return InputDeviceCategory.PlayStation; + } + + return InputDeviceCategory.Other; + } + + private static bool DescriptionContains(InputDevice device, string value) + { + if (device == null) + { + return false; + } + + var description = device.description; + return ContainsIgnoreCase(description.interfaceName, value) + || ContainsIgnoreCase(device.layout, value) + || ContainsIgnoreCase(description.product, value) + || ContainsIgnoreCase(description.manufacturer, value) + || ContainsIgnoreCase(device.displayName, value) + || ContainsIgnoreCase(device.name, value); + } + + private static bool TryParseVendorProductIds(string capabilities, out int vendorId, out int productId) + { + vendorId = 0; + productId = 0; + if (string.IsNullOrWhiteSpace(capabilities)) + { + return false; + } + + try + { + DeviceCapabilityInfo info = JsonUtility.FromJson(capabilities); + vendorId = info.vendorId; + productId = info.productId; + return vendorId != 0 || productId != 0; + } + catch + { + return false; + } + } + + private static bool ContainsIgnoreCase(string source, string value) + { + return !string.IsNullOrEmpty(source) && source.IndexOf(value, StringComparison.OrdinalIgnoreCase) >= 0; + } +} diff --git a/Runtime/InputGlyph/Core/InputDeviceWatcher.cs.meta b/Runtime/InputGlyph/Core/InputDeviceWatcher.cs.meta new file mode 100644 index 0000000..ac22b49 --- /dev/null +++ b/Runtime/InputGlyph/Core/InputDeviceWatcher.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e78f6224467e13742a70115f1942d941 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/InputGlyph/Data.meta b/Runtime/InputGlyph/Data.meta new file mode 100644 index 0000000..ec7acce --- /dev/null +++ b/Runtime/InputGlyph/Data.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 096043edb2be8224f8564b40992f588b +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/InputGlyph/Data/InputGlyphDatabase.cs b/Runtime/InputGlyph/Data/InputGlyphDatabase.cs new file mode 100644 index 0000000..59690a2 --- /dev/null +++ b/Runtime/InputGlyph/Data/InputGlyphDatabase.cs @@ -0,0 +1,359 @@ +using System; +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.InputSystem; + +[Serializable] +public sealed class GlyphEntry +{ + public Sprite Sprite; + public InputAction action; +} + +[Serializable] +public sealed class DeviceGlyphTable +{ + public string deviceName; + public Texture2D spriteSheetTexture; + public Sprite platformIcons; + public List entries = new List(); +} + +[CreateAssetMenu(fileName = "InputGlyphDatabase", menuName = "GameplaySystem/Input/InputGlyphDatabase", order = 400)] +public sealed class InputGlyphDatabase : ScriptableObject +{ + private const string DeviceKeyboard = "Keyboard"; + private const string DeviceXbox = "Xbox"; + private const string DevicePlayStation = "PlayStation"; + private const string DeviceOther = "Other"; + private static readonly InputDeviceWatcher.InputDeviceCategory[] KeyboardLookupOrder = { InputDeviceWatcher.InputDeviceCategory.Keyboard }; + private static readonly InputDeviceWatcher.InputDeviceCategory[] XboxLookupOrder = + { + InputDeviceWatcher.InputDeviceCategory.Xbox, + InputDeviceWatcher.InputDeviceCategory.Other, + InputDeviceWatcher.InputDeviceCategory.Keyboard, + }; + private static readonly InputDeviceWatcher.InputDeviceCategory[] PlayStationLookupOrder = + { + InputDeviceWatcher.InputDeviceCategory.PlayStation, + InputDeviceWatcher.InputDeviceCategory.Other, + InputDeviceWatcher.InputDeviceCategory.Keyboard, + }; + private static readonly InputDeviceWatcher.InputDeviceCategory[] OtherLookupOrder = + { + InputDeviceWatcher.InputDeviceCategory.Other, + InputDeviceWatcher.InputDeviceCategory.Xbox, + InputDeviceWatcher.InputDeviceCategory.Keyboard, + }; + private static readonly Dictionary NormalizedPathCache = new(StringComparer.Ordinal); + + public List tables = new List(); + public Sprite placeholderSprite; + + private Dictionary _tableCache; + private Dictionary> _pathLookup; + + private void OnEnable() + { + BuildCache(); + } + +#if UNITY_EDITOR + private void OnValidate() + { + BuildCache(); + } +#endif + + public DeviceGlyphTable GetTable(string deviceName) + { + if (string.IsNullOrWhiteSpace(deviceName) || tables == null) + { + return null; + } + + EnsureCache(); + _tableCache.TryGetValue(deviceName, out DeviceGlyphTable table); + return table; + } + + public DeviceGlyphTable GetTable(InputDeviceWatcher.InputDeviceCategory device) + { + switch (device) + { + case InputDeviceWatcher.InputDeviceCategory.Keyboard: + return GetTable(DeviceKeyboard); + case InputDeviceWatcher.InputDeviceCategory.Xbox: + return GetTable(DeviceXbox); + case InputDeviceWatcher.InputDeviceCategory.PlayStation: + return GetTable(DevicePlayStation); + default: + return GetTable(DeviceOther) ?? GetTable(DeviceXbox); + } + } + + public Sprite GetPlatformIcon(InputDeviceWatcher.InputDeviceCategory device) + { + DeviceGlyphTable table = GetTable(device); + return table != null ? table.platformIcons : null; + } + + public bool TryGetSprite(string controlPath, InputDeviceWatcher.InputDeviceCategory device, out Sprite sprite) + { + EnsureCache(); + string key = NormalizeControlPath(controlPath); + if (string.IsNullOrEmpty(key)) + { + sprite = placeholderSprite; + return sprite != null; + } + + InputDeviceWatcher.InputDeviceCategory[] lookupOrder = GetLookupOrder(device); + for (int i = 0; i < lookupOrder.Length; i++) + { + InputDeviceWatcher.InputDeviceCategory category = lookupOrder[i]; + if (_pathLookup.TryGetValue(category, out Dictionary map) && map.TryGetValue(key, out sprite) && sprite != null) + { + return true; + } + } + + sprite = placeholderSprite; + return sprite != null; + } + + public Sprite FindSprite(string controlPath, InputDeviceWatcher.InputDeviceCategory device) + { + return TryGetSprite(controlPath, device, out Sprite sprite) ? sprite : placeholderSprite; + } + + public GlyphEntry FindEntryByControlPath(string controlPath, InputDeviceWatcher.InputDeviceCategory device) + { + if (!TryGetSprite(controlPath, device, out Sprite sprite) || sprite == null) + { + return null; + } + + InputDeviceWatcher.InputDeviceCategory[] lookupOrder = GetLookupOrder(device); + for (int i = 0; i < lookupOrder.Length; i++) + { + DeviceGlyphTable table = GetTable(lookupOrder[i]); + if (table == null || table.entries == null) + { + continue; + } + + for (int j = 0; j < table.entries.Count; j++) + { + GlyphEntry entry = table.entries[j]; + if (entry != null && entry.Sprite == sprite) + { + return entry; + } + } + } + + return null; + } + + private void EnsureCache() + { + if (_tableCache == null || _pathLookup == null) + { + BuildCache(); + } + } + + private void BuildCache() + { + _tableCache ??= new Dictionary(StringComparer.OrdinalIgnoreCase); + _tableCache.Clear(); + + _pathLookup ??= new Dictionary>(); + _pathLookup.Clear(); + InitializeLookup(InputDeviceWatcher.InputDeviceCategory.Keyboard); + InitializeLookup(InputDeviceWatcher.InputDeviceCategory.Xbox); + InitializeLookup(InputDeviceWatcher.InputDeviceCategory.PlayStation); + InitializeLookup(InputDeviceWatcher.InputDeviceCategory.Other); + + if (tables == null) + { + return; + } + + for (int i = 0; i < tables.Count; i++) + { + DeviceGlyphTable table = tables[i]; + if (table == null || string.IsNullOrWhiteSpace(table.deviceName)) + { + continue; + } + + _tableCache[table.deviceName] = table; + InputDeviceWatcher.InputDeviceCategory category = ParseCategory(table.deviceName); + Dictionary map = _pathLookup[category]; + RegisterEntries(table, map); + } + } + +#if UNITY_EDITOR + public void EditorRefreshCache() + { + BuildCache(); + } + + public static string EditorNormalizeControlPath(string controlPath) + { + return NormalizeControlPath(controlPath); + } +#endif + + private void InitializeLookup(InputDeviceWatcher.InputDeviceCategory category) + { + _pathLookup[category] = new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + private void RegisterEntries(DeviceGlyphTable table, Dictionary map) + { + if (table.entries == null) + { + return; + } + + for (int i = 0; i < table.entries.Count; i++) + { + GlyphEntry entry = table.entries[i]; + if (entry == null || entry.Sprite == null || entry.action == null) + { + continue; + } + + for (int j = 0; j < entry.action.bindings.Count; j++) + { + RegisterBinding(map, entry.action.bindings[j].path, entry.Sprite); + RegisterBinding(map, entry.action.bindings[j].effectivePath, entry.Sprite); + } + } + } + + private void RegisterBinding(Dictionary map, string controlPath, Sprite sprite) + { + string key = NormalizeControlPath(controlPath); + if (string.IsNullOrEmpty(key) || map.ContainsKey(key)) + { + return; + } + + map[key] = sprite; + } + + private static string NormalizeControlPath(string controlPath) + { + if (string.IsNullOrWhiteSpace(controlPath)) + { + return string.Empty; + } + + if (NormalizedPathCache.TryGetValue(controlPath, out string normalizedPath)) + { + return normalizedPath; + } + + normalizedPath = CanonicalizeDeviceLayout(controlPath.Trim().ToLowerInvariant()); + NormalizedPathCache[controlPath] = normalizedPath; + return normalizedPath; + } + + private static string CanonicalizeDeviceLayout(string controlPath) + { + int start = controlPath.IndexOf('<'); + int end = controlPath.IndexOf('>'); + if (start < 0 || end <= start + 1) + { + return controlPath; + } + + string layout = controlPath.Substring(start + 1, end - start - 1); + string canonicalLayout = GetCanonicalLayout(layout); + if (string.Equals(layout, canonicalLayout, StringComparison.Ordinal)) + { + return controlPath; + } + + return controlPath.Substring(0, start + 1) + canonicalLayout + controlPath.Substring(end); + } + + private static string GetCanonicalLayout(string layout) + { + if (string.IsNullOrEmpty(layout)) + { + return string.Empty; + } + + if (layout.IndexOf("keyboard", StringComparison.OrdinalIgnoreCase) >= 0) + { + return "keyboard"; + } + + if (layout.IndexOf("mouse", StringComparison.OrdinalIgnoreCase) >= 0) + { + return "mouse"; + } + + if (layout.IndexOf("joystick", StringComparison.OrdinalIgnoreCase) >= 0) + { + return "joystick"; + } + + if (layout.IndexOf("gamepad", StringComparison.OrdinalIgnoreCase) >= 0 + || layout.IndexOf("controller", StringComparison.OrdinalIgnoreCase) >= 0 + || layout.IndexOf("xinput", StringComparison.OrdinalIgnoreCase) >= 0 + || layout.IndexOf("dualshock", StringComparison.OrdinalIgnoreCase) >= 0 + || layout.IndexOf("dualsense", StringComparison.OrdinalIgnoreCase) >= 0) + { + return "gamepad"; + } + + return layout; + } + + private static InputDeviceWatcher.InputDeviceCategory ParseCategory(string deviceName) + { + if (string.IsNullOrWhiteSpace(deviceName)) + { + return InputDeviceWatcher.InputDeviceCategory.Other; + } + + if (deviceName.Equals(DeviceKeyboard, StringComparison.OrdinalIgnoreCase)) + { + return InputDeviceWatcher.InputDeviceCategory.Keyboard; + } + + if (deviceName.Equals(DeviceXbox, StringComparison.OrdinalIgnoreCase)) + { + return InputDeviceWatcher.InputDeviceCategory.Xbox; + } + + if (deviceName.Equals(DevicePlayStation, StringComparison.OrdinalIgnoreCase)) + { + return InputDeviceWatcher.InputDeviceCategory.PlayStation; + } + + return InputDeviceWatcher.InputDeviceCategory.Other; + } + + private static InputDeviceWatcher.InputDeviceCategory[] GetLookupOrder(InputDeviceWatcher.InputDeviceCategory device) + { + switch (device) + { + case InputDeviceWatcher.InputDeviceCategory.Keyboard: + return KeyboardLookupOrder; + case InputDeviceWatcher.InputDeviceCategory.Xbox: + return XboxLookupOrder; + case InputDeviceWatcher.InputDeviceCategory.PlayStation: + return PlayStationLookupOrder; + default: + return OtherLookupOrder; + } + } +} diff --git a/Runtime/InputGlyph/Data/InputGlyphDatabase.cs.meta b/Runtime/InputGlyph/Data/InputGlyphDatabase.cs.meta new file mode 100644 index 0000000..c665347 --- /dev/null +++ b/Runtime/InputGlyph/Data/InputGlyphDatabase.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 53ed017cef844d11842ba16553c6391d +timeCreated: 1764917621 \ No newline at end of file diff --git a/Runtime/InputGlyph/Editor.meta b/Runtime/InputGlyph/Editor.meta new file mode 100644 index 0000000..3f1f5cc --- /dev/null +++ b/Runtime/InputGlyph/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 241d5382b2b4f274596c73f14a40cb8d +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/InputGlyph/Editor/InputGlyphDatabaseEditor.cs b/Runtime/InputGlyph/Editor/InputGlyphDatabaseEditor.cs new file mode 100644 index 0000000..a943608 --- /dev/null +++ b/Runtime/InputGlyph/Editor/InputGlyphDatabaseEditor.cs @@ -0,0 +1,1189 @@ +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; + } +} diff --git a/Runtime/InputGlyph/Editor/InputGlyphDatabaseEditor.cs.meta b/Runtime/InputGlyph/Editor/InputGlyphDatabaseEditor.cs.meta new file mode 100644 index 0000000..718efbb --- /dev/null +++ b/Runtime/InputGlyph/Editor/InputGlyphDatabaseEditor.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 96b54c2be64d4891a50c4db8b36bf839 +timeCreated: 1764923573 \ No newline at end of file diff --git a/Runtime/InputGlyph/Editor/InputGlyphEditor.cs b/Runtime/InputGlyph/Editor/InputGlyphEditor.cs new file mode 100644 index 0000000..ed98e75 --- /dev/null +++ b/Runtime/InputGlyph/Editor/InputGlyphEditor.cs @@ -0,0 +1,303 @@ +using System; +using System.Collections.Generic; +using TMPro; +using UnityEditor; +using UnityEngine; +using UnityEngine.InputSystem; + +[CustomEditor(typeof(InputGlyph))] +[CanEditMultipleObjects] +public sealed class InputGlyphEditor : Editor +{ + private SerializedProperty _actionSourceMode; + private SerializedProperty _actionReference; + private SerializedProperty _hotkeyTrigger; + private SerializedProperty _actionName; + private SerializedProperty _compositePartName; + private SerializedProperty _outputMode; + private SerializedProperty _targetImage; + private SerializedProperty _targetText; + private SerializedProperty _categoryEvents; + + private GUIStyle _titleStyle; + private GUIStyle _sectionStyle; + private GUIStyle _hintStyle; + + private void OnEnable() + { + _actionSourceMode = serializedObject.FindProperty("actionSourceMode"); + _actionReference = serializedObject.FindProperty("actionReference"); + _hotkeyTrigger = serializedObject.FindProperty("hotkeyTrigger"); + _actionName = serializedObject.FindProperty("actionName"); + _compositePartName = serializedObject.FindProperty("compositePartName"); + _outputMode = serializedObject.FindProperty("outputMode"); + _targetImage = serializedObject.FindProperty("targetImage"); + _targetText = serializedObject.FindProperty("targetText"); + _categoryEvents = serializedObject.FindProperty("categoryEvents"); + BuildStyles(); + } + + public override void OnInspectorGUI() + { + serializedObject.Update(); + + DrawSourceSection(); + DrawOutputSection(); + DrawEventsSection(); + + serializedObject.ApplyModifiedProperties(); + } + + + private void DrawSourceSection() + { + InputAction resolvedAction = ResolveSelectedAction(); + + EditorGUILayout.BeginVertical(_sectionStyle); + EditorGUILayout.PropertyField(_actionSourceMode, new GUIContent("Reference Mode")); + DrawSourceFields(); + DrawResolvedActionInfo(resolvedAction); + DrawCompositePartField(resolvedAction); + EditorGUILayout.EndVertical(); + EditorGUILayout.Space(6f); + } + + private void DrawSourceFields() + { + InputGlyph.ActionSourceMode mode = (InputGlyph.ActionSourceMode)_actionSourceMode.enumValueIndex; + switch (mode) + { + case InputGlyph.ActionSourceMode.ActionReference: + EditorGUILayout.PropertyField(_actionReference, new GUIContent("Action Reference")); + EditorGUILayout.LabelField("Use a direct InputActionReference.", _hintStyle); + break; + case InputGlyph.ActionSourceMode.HotkeyTrigger: + EditorGUILayout.PropertyField(_hotkeyTrigger, new GUIContent("Hotkey Trigger")); + Component component = _hotkeyTrigger.objectReferenceValue as Component; + if (component != null && !(component is UnityEngine.UI.IHotkeyTrigger)) + { + EditorGUILayout.HelpBox("Hotkey Trigger must implement IHotkeyTrigger.", MessageType.Warning); + } + else + { + EditorGUILayout.LabelField("Reads the action from an external IHotkeyTrigger component.", _hintStyle); + } + + break; + case InputGlyph.ActionSourceMode.ActionName: + EditorGUILayout.PropertyField(_actionName, new GUIContent("Action Name")); + EditorGUILayout.LabelField("Supports ActionName or MapName/ActionName.", _hintStyle); + break; + } + } + + private void DrawOutputSection() + { + EditorGUILayout.BeginVertical(_sectionStyle); + EditorGUILayout.PropertyField(_outputMode, new GUIContent("Render Mode")); + DrawOutputFields(); + EditorGUILayout.EndVertical(); + EditorGUILayout.Space(6f); + } + + + private void DrawOutputFields() + { + InputGlyph.OutputMode mode = (InputGlyph.OutputMode)_outputMode.enumValueIndex; + switch (mode) + { + case InputGlyph.OutputMode.Image: + EditorGUILayout.PropertyField(_targetImage, new GUIContent("Target Image")); + EditorGUILayout.LabelField("Shows the resolved sprite on a Unity UI Image.", _hintStyle); + break; + case InputGlyph.OutputMode.Text: + EditorGUILayout.PropertyField(_targetText, new GUIContent("Target TMP Text")); + EditorGUILayout.LabelField("Uses the current TMP text as a template and replaces {0}.", _hintStyle); + TMP_Text text = _targetText.objectReferenceValue as TMP_Text; + if (text == null) + { + EditorGUILayout.HelpBox("If TMP_Text is empty, the component tries GetComponent().", MessageType.None); + } + + break; + } + } + + private void DrawEventsSection() + { + EditorGUILayout.BeginVertical(_sectionStyle); + EditorGUILayout.PropertyField(_categoryEvents, new GUIContent("Category Events"), true); + EditorGUILayout.EndVertical(); + } + + private void DrawResolvedActionInfo(InputAction action) + { + if (action == null) + { + return; + } + + string mapName = action.actionMap != null ? action.actionMap.name : ""; + EditorGUILayout.LabelField($"Resolved Action: {mapName}/{action.name}", _hintStyle); + } + + private void DrawCompositePartField(InputAction action) + { + List compositeParts = CollectCompositePartNames(action); + if (compositeParts.Count == 0) + { + if (!string.IsNullOrEmpty(_compositePartName.stringValue)) + { + _compositePartName.stringValue = string.Empty; + } + + return; + } + + string[] options = new string[compositeParts.Count + 1]; + options[0] = ""; + for (int i = 0; i < compositeParts.Count; i++) + { + options[i + 1] = compositeParts[i]; + } + + int selectedIndex = 0; + for (int i = 0; i < compositeParts.Count; i++) + { + if (string.Equals(compositeParts[i], _compositePartName.stringValue, StringComparison.OrdinalIgnoreCase)) + { + selectedIndex = i + 1; + break; + } + } + + int newIndex = EditorGUILayout.Popup(new GUIContent("Composite Part"), selectedIndex, options); + _compositePartName.stringValue = newIndex <= 0 ? string.Empty : compositeParts[newIndex - 1]; + EditorGUILayout.LabelField("Shown only when the resolved action contains composite bindings.", _hintStyle); + } + + private InputAction ResolveSelectedAction() + { + InputGlyph.ActionSourceMode mode = (InputGlyph.ActionSourceMode)_actionSourceMode.enumValueIndex; + switch (mode) + { + case InputGlyph.ActionSourceMode.ActionReference: + InputActionReference actionReference = _actionReference.objectReferenceValue as InputActionReference; + return actionReference != null ? actionReference.action : null; + case InputGlyph.ActionSourceMode.HotkeyTrigger: + Component component = _hotkeyTrigger.objectReferenceValue as Component; + if (component is UnityEngine.UI.IHotkeyTrigger trigger && trigger.HotkeyAction != null) + { + return trigger.HotkeyAction.action; + } + + return null; + case InputGlyph.ActionSourceMode.ActionName: + return ResolveActionByName(_actionName.stringValue); + default: + return null; + } + } + + private InputAction ResolveActionByName(string actionName) + { + if (string.IsNullOrWhiteSpace(actionName)) + { + return null; + } + + foreach (InputActionAsset asset in EnumerateInputActionAssets()) + { + if (asset == null) + { + continue; + } + + InputAction action = asset.FindAction(actionName, false); + if (action != null) + { + return action; + } + } + + return null; + } + + private IEnumerable EnumerateInputActionAssets() + { + HashSet visited = new HashSet(); + InputBindingManager[] managers = Resources.FindObjectsOfTypeAll(); + for (int i = 0; i < managers.Length; i++) + { + InputActionAsset asset = managers[i] != null ? managers[i].actions : null; + if (asset != null && visited.Add(asset)) + { + yield return asset; + } + } + + string[] guids = AssetDatabase.FindAssets("t:InputActionAsset"); + for (int i = 0; i < guids.Length; i++) + { + string path = AssetDatabase.GUIDToAssetPath(guids[i]); + InputActionAsset asset = AssetDatabase.LoadAssetAtPath(path); + if (asset != null && visited.Add(asset)) + { + yield return asset; + } + } + } + + private static List CollectCompositePartNames(InputAction action) + { + List parts = new List(); + if (action == null) + { + return parts; + } + + HashSet uniqueParts = new HashSet(StringComparer.OrdinalIgnoreCase); + for (int i = 0; i < action.bindings.Count; i++) + { + InputBinding binding = action.bindings[i]; + if (!binding.isPartOfComposite || string.IsNullOrWhiteSpace(binding.name)) + { + continue; + } + + if (uniqueParts.Add(binding.name)) + { + parts.Add(binding.name); + } + } + + return parts; + } + + private void BuildStyles() + { + if (_titleStyle == null) + { + _titleStyle = new GUIStyle(EditorStyles.boldLabel) + { + fontSize = 14 + }; + } + + if (_sectionStyle == null) + { + _sectionStyle = new GUIStyle(EditorStyles.helpBox) + { + padding = new RectOffset(12, 12, 10, 10) + }; + } + + if (_hintStyle == null) + { + _hintStyle = new GUIStyle(EditorStyles.miniLabel) + { + wordWrap = true + }; + } + } +} diff --git a/Runtime/InputGlyph/Editor/InputGlyphEditor.cs.meta b/Runtime/InputGlyph/Editor/InputGlyphEditor.cs.meta new file mode 100644 index 0000000..c790b7a --- /dev/null +++ b/Runtime/InputGlyph/Editor/InputGlyphEditor.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 72d9df70bb4f43f6a73be92b5f332871 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/InputGlyph/InputGlyph.cs b/Runtime/InputGlyph/InputGlyph.cs new file mode 100644 index 0000000..188e26a --- /dev/null +++ b/Runtime/InputGlyph/InputGlyph.cs @@ -0,0 +1,296 @@ +using System; +using System.Collections.Generic; +using AlicizaX; +using TMPro; +using UnityEngine; +using UnityEngine.Events; +using UnityEngine.InputSystem; +using UnityEngine.UI; + +[AddComponentMenu("UI/Input Glyph")] +public sealed class InputGlyph : InputGlyphBehaviourBase +{ + public enum ActionSourceMode + { + ActionReference, + HotkeyTrigger, + ActionName + } + + public enum OutputMode + { + Image, + Text + } + + [Serializable] + public sealed class DeviceCategoryEvent + { + public InputDeviceWatcher.InputDeviceCategory category; + public UnityEvent onMatched; + public UnityEvent onNotMatched; + } + + [Header("Source")] + [SerializeField] private ActionSourceMode actionSourceMode = ActionSourceMode.ActionReference; + [SerializeField] private InputActionReference actionReference; + [SerializeField] private Component hotkeyTrigger; + [SerializeField] private string actionName; + [SerializeField] private string compositePartName; + + [Header("Output")] + [SerializeField] private OutputMode outputMode = OutputMode.Image; + [SerializeField] private Image targetImage; + [SerializeField] private TMP_Text targetText; + + [Header("Platform Events")] + [SerializeField] private List categoryEvents = new(); + + private Sprite _cachedSprite; + private string _templateText; + private string _cachedFormattedText; + private string _cachedReplacementToken; + private bool _hasInvokedCategoryEvent; + private InputDeviceWatcher.InputDeviceCategory _lastInvokedCategory; + +#if UNITY_EDITOR + private void OnValidate() + { + AutoAssignHotkeyTrigger(); + AutoAssignTarget(); + } +#endif + + protected override void OnEnable() + { + AutoAssignHotkeyTrigger(); + AutoAssignTarget(); + CacheTemplateText(); + base.OnEnable(); + InvokeCategoryEvents(true); + } + + protected override void OnDeviceCategoryChanged( + InputDeviceWatcher.InputDeviceCategory previousCategory, + InputDeviceWatcher.InputDeviceCategory newCategory) + { + if (previousCategory == newCategory) + { + return; + } + + InvokeCategoryEvents(false); + } + + protected override void RefreshGlyph() + { + InputAction action = ResolveAction(); + switch (outputMode) + { + case OutputMode.Image: + RefreshImage(action); + break; + case OutputMode.Text: + RefreshText(action); + break; + } + } + + private void RefreshImage(InputAction action) + { + if (targetImage == null) + { + return; + } + + if (action == null) + { + ClearImage(); + return; + } + + bool hasSprite = GlyphService.TryGetUISpriteForActionPath(action, compositePartName, CurrentCategory, out Sprite sprite); + if (!hasSprite) + { + sprite = null; + } + + if (_cachedSprite != sprite || targetImage.sprite != sprite) + { + _cachedSprite = sprite; + targetImage.sprite = sprite; + } + } + + private void RefreshText(InputAction action) + { + if (targetText == null) + { + return; + } + + CacheTemplateText(); + if (action == null) + { + ResetText(); + return; + } + + string replacementToken; + if (GlyphService.TryGetTMPTagForActionPath(action, compositePartName, CurrentCategory, out string tag, out string displayFallback)) + { + replacementToken = tag; + } + else + { + replacementToken = displayFallback; + } + + if (string.IsNullOrEmpty(replacementToken)) + { + ResetText(); + return; + } + + string formattedText = Utility.Text.Format(_templateText, replacementToken); + if (_cachedReplacementToken == replacementToken + && _cachedFormattedText == formattedText + && targetText.text == formattedText) + { + return; + } + + _cachedReplacementToken = replacementToken; + if (_cachedFormattedText != formattedText || targetText.text != formattedText) + { + _cachedFormattedText = formattedText; + targetText.text = formattedText; + } + } + + private InputAction ResolveAction() + { + switch (actionSourceMode) + { + case ActionSourceMode.ActionReference: + return actionReference != null ? actionReference.action : null; + case ActionSourceMode.HotkeyTrigger: + return ResolveHotkeyAction(); + case ActionSourceMode.ActionName: + return InputBindingManager.TryGetAction(actionName, out InputAction action) ? action : null; + default: + return null; + } + } + + private InputAction ResolveHotkeyAction() + { + IHotkeyTrigger trigger = ResolveHotkeyTrigger(); + return trigger != null && trigger.HotkeyAction != null ? trigger.HotkeyAction.action : null; + } + + private IHotkeyTrigger ResolveHotkeyTrigger() + { + AutoAssignHotkeyTrigger(); + return hotkeyTrigger as IHotkeyTrigger; + } + + private void AutoAssignHotkeyTrigger() + { + if (actionSourceMode != ActionSourceMode.HotkeyTrigger || hotkeyTrigger != null) + { + return; + } + + if (TryGetComponent(typeof(IHotkeyTrigger), out Component component)) + { + hotkeyTrigger = component; + } + } + + private void AutoAssignTarget() + { + switch (outputMode) + { + case OutputMode.Image: + if (targetImage == null) + { + targetImage = GetComponent(); + } + + break; + case OutputMode.Text: + if (targetText == null) + { + targetText = GetComponent(); + } + + break; + } + } + + private void CacheTemplateText() + { + if (targetText == null) + { + return; + } + + if (string.IsNullOrEmpty(_templateText)) + { + _templateText = targetText.text; + } + } + + private void ResetText() + { + _cachedReplacementToken = null; + _cachedFormattedText = null; + if (targetText != null && targetText.text != _templateText) + { + targetText.text = _templateText; + } + } + + private void ClearImage() + { + _cachedSprite = null; + if (targetImage != null && targetImage.sprite != null) + { + targetImage.sprite = null; + } + } + + private void InvokeCategoryEvents(bool force) + { + if (!force && _hasInvokedCategoryEvent && _lastInvokedCategory == CurrentCategory) + { + return; + } + + _hasInvokedCategoryEvent = true; + _lastInvokedCategory = CurrentCategory; + if (categoryEvents == null) + { + return; + } + + for (int i = 0; i < categoryEvents.Count; i++) + { + DeviceCategoryEvent categoryEvent = categoryEvents[i]; + if (categoryEvent == null) + { + continue; + } + + if (categoryEvent.category == CurrentCategory) + { + categoryEvent.onMatched?.Invoke(); + } + else + { + categoryEvent.onNotMatched?.Invoke(); + } + } + } +} diff --git a/Runtime/InputGlyph/InputGlyph.cs.meta b/Runtime/InputGlyph/InputGlyph.cs.meta new file mode 100644 index 0000000..6473402 --- /dev/null +++ b/Runtime/InputGlyph/InputGlyph.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 211cfb186fc74ca694ec6f7f4b0fd933 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/InputGlyph/InputGlyphBehaviourBase.cs b/Runtime/InputGlyph/InputGlyphBehaviourBase.cs new file mode 100644 index 0000000..ad10127 --- /dev/null +++ b/Runtime/InputGlyph/InputGlyphBehaviourBase.cs @@ -0,0 +1,41 @@ +using UnityEngine; + +public abstract class InputGlyphBehaviourBase : MonoBehaviour +{ + protected InputDeviceWatcher.InputDeviceCategory CurrentCategory { get; private set; } + + protected virtual void OnEnable() + { + CurrentCategory = InputDeviceWatcher.CurrentCategory; + InputDeviceWatcher.OnDeviceChanged += HandleDeviceChanged; + InputBindingManager.BindingsChanged += HandleBindingsChanged; + RefreshGlyph(); + } + + protected virtual void OnDisable() + { + InputDeviceWatcher.OnDeviceChanged -= HandleDeviceChanged; + InputBindingManager.BindingsChanged -= HandleBindingsChanged; + } + + private void HandleDeviceChanged(InputDeviceWatcher.InputDeviceCategory category) + { + InputDeviceWatcher.InputDeviceCategory previousCategory = CurrentCategory; + CurrentCategory = category; + OnDeviceCategoryChanged(previousCategory, category); + RefreshGlyph(); + } + + private void HandleBindingsChanged() + { + RefreshGlyph(); + } + + protected virtual void OnDeviceCategoryChanged( + InputDeviceWatcher.InputDeviceCategory previousCategory, + InputDeviceWatcher.InputDeviceCategory newCategory) + { + } + + protected abstract void RefreshGlyph(); +} diff --git a/Runtime/InputGlyph/InputGlyphBehaviourBase.cs.meta b/Runtime/InputGlyph/InputGlyphBehaviourBase.cs.meta new file mode 100644 index 0000000..8d7ad76 --- /dev/null +++ b/Runtime/InputGlyph/InputGlyphBehaviourBase.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a16393b81b47f1844a49d492284be475 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/InputGlyph/TestRebindScript.cs b/Runtime/InputGlyph/TestRebindScript.cs new file mode 100644 index 0000000..f8cc3bb --- /dev/null +++ b/Runtime/InputGlyph/TestRebindScript.cs @@ -0,0 +1,245 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using TMPro; +using UnityEngine; +using UnityEngine.InputSystem; +using UnityEngine.UI; + +public class TestRebindScript : MonoBehaviour +{ + [Header("UI")] public UXButton btn; + public TextMeshProUGUI bindKeyText; + public Image targetImage; + + [Tooltip("如果不使用 actionReference,则用 name 在全局 manager 查找")] + public string actionName = "movement"; + + [Header("Optional composite part (WASD style)")] [Tooltip("如果需要绑定 composite 的某一部分(例如 Up/Down/Left/Right),填这个;留空表示绑定非 composite 或整体 binding")] + public string compositePartName = ""; + + [Header("Behavior")] [Tooltip("如果 true,在 Prepare 后自动调用 ConfirmApply() 并保存;否则等待手动 ConfirmPrepared()/CancelPrepared()")] + public bool autoConfirm = false; + + /// + /// 启动时初始化并订阅事件 + /// + private void Start() + { + if (btn != null) btn.onClick.AddListener(OnBtnClicked); + InputDeviceWatcher.OnDeviceChanged += OnDeviceChanged; + InputBindingManager.BindingsChanged += OnBindingsChanged; + UpdateBindingText(); + + if (InputBindingManager.Instance != null) + { + // 订阅事件 + InputBindingManager.Instance.OnRebindPrepare += OnRebindPrepareHandler; + InputBindingManager.Instance.OnApply += OnApplyHandler; + InputBindingManager.Instance.OnRebindEnd += OnRebindEndHandler; + InputBindingManager.Instance.OnRebindConflict += OnRebindConflictHandler; + } + } + + /// + /// 禁用时取消订阅事件 + /// + private void OnDisable() + { + if (btn != null) btn.onClick.RemoveListener(OnBtnClicked); + InputDeviceWatcher.OnDeviceChanged -= OnDeviceChanged; + InputBindingManager.BindingsChanged -= OnBindingsChanged; + + if (InputBindingManager.Instance != null) + { + InputBindingManager.Instance.OnRebindPrepare -= OnRebindPrepareHandler; + InputBindingManager.Instance.OnApply -= OnApplyHandler; + InputBindingManager.Instance.OnRebindEnd -= OnRebindEndHandler; + InputBindingManager.Instance.OnRebindConflict -= OnRebindConflictHandler; + } + } + + /// + /// 重新绑定准备完成的处理器 + /// + private void OnRebindPrepareHandler(InputBindingManager.RebindContext ctx) + { + if (IsTargetContext(ctx)) + { + var disp = ctx.overridePath == InputBindingManager.NULL_BINDING ? "" : ctx.overridePath; + bindKeyText.text = disp; + if (autoConfirm) _ = ConfirmPreparedAsync(); + } + } + + /// + /// 应用重新绑定的处理器 + /// + private void OnApplyHandler(bool success, HashSet appliedContexts) + { + if (appliedContexts != null) + { + // 仅当任何应用/丢弃的上下文与此实例匹配时才更新 + foreach (var ctx in appliedContexts) + { + if (IsTargetContext(ctx)) + { + UpdateBindingText(); + break; + } + } + } + } + + /// + /// 重新绑定结束的处理器 + /// + private void OnRebindEndHandler(bool success, InputBindingManager.RebindContext context) + { + if (IsTargetContext(context)) + { + UpdateBindingText(); + } + } + + /// + /// 重新绑定冲突的处理器 + /// + private void OnRebindConflictHandler(InputBindingManager.RebindContext prepared, InputBindingManager.RebindContext conflict) + { + // 如果准备的或冲突的上下文匹配此实例,则更新 + if (IsTargetContext(prepared) || IsTargetContext(conflict)) + { + UpdateBindingText(); + } + } + + /// + /// 设备变更的回调 + /// + private void OnDeviceChanged(InputDeviceWatcher.InputDeviceCategory _) + { + UpdateBindingText(); + } + + private void OnBindingsChanged() + { + UpdateBindingText(); + } + + /// + /// 获取当前的输入操作 + /// + private InputAction GetAction() + { + return InputBindingManager.Action(actionName); + } + + /// + /// 判断上下文是否为目标上下文 + /// + private bool IsTargetContext(InputBindingManager.RebindContext ctx) + { + if (ctx == null || ctx.action == null) return false; + var action = GetAction(); + if (action == null) return false; + + // 必须匹配操作 + if (ctx.action != action) return false; + + // 如果指定了复合部分,需要匹配绑定索引 + if (!string.IsNullOrEmpty(compositePartName)) + { + // 获取上下文索引处的绑定 + if (ctx.bindingIndex < 0 || ctx.bindingIndex >= action.bindings.Count) + return false; + + var binding = action.bindings[ctx.bindingIndex]; + + // 检查绑定的名称是否与我们的复合部分匹配 + return string.Equals(binding.name, compositePartName, StringComparison.OrdinalIgnoreCase); + } + + // 如果未指定复合部分,仅匹配操作就足够了 + return true; + } + + /// + /// 按钮点击的回调 + /// + private void OnBtnClicked() + { + // 使用管理器 API(我们传递部分名称,以便管理器可以在需要时选择适当的绑定) + InputBindingManager.StartRebind(actionName, string.IsNullOrEmpty(compositePartName) ? null : compositePartName); + } + + /// + /// 确认准备好的重新绑定(公共方法) + /// + public async void ConfirmPrepared() + { + bool ok = await ConfirmPreparedAsync(); + if (!ok) Debug.LogError("ConfirmPrepared: apply failed."); + } + + /// + /// 确认准备好的重新绑定(异步) + /// + private async Task ConfirmPreparedAsync() + { + try + { + var task = InputBindingManager.ConfirmApply(); + return await task; + } + catch (Exception ex) + { + Debug.LogError(ex); + return false; + } + } + + /// + /// 取消准备好的重新绑定 + /// + public void CancelPrepared() + { + InputBindingManager.DiscardPrepared(); + // UpdateBindingText 将通过 OnApply 事件自动调用 + } + + /// + /// 更新绑定文本和图标显示 + /// + private void UpdateBindingText() + { + var action = GetAction(); + var deviceCat = InputDeviceWatcher.CurrentCategory; + if (action == null) + { + bindKeyText.text = ""; + if (targetImage != null) targetImage.sprite = null; + return; + } + + + bindKeyText.text = GlyphService.GetDisplayNameFromInputAction(action, compositePartName, deviceCat); + + + try + { + if (GlyphService.TryGetUISpriteForActionPath(action, compositePartName, deviceCat, out Sprite sprite)) + { + if (targetImage != null) targetImage.sprite = sprite; + } + else + { + if (targetImage != null) targetImage.sprite = null; + } + } + catch + { + if (targetImage != null) targetImage.sprite = null; + } + } +} diff --git a/Runtime/InputGlyph/TestRebindScript.cs.meta b/Runtime/InputGlyph/TestRebindScript.cs.meta new file mode 100644 index 0000000..618b27d --- /dev/null +++ b/Runtime/InputGlyph/TestRebindScript.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5ec2871cc330674438e5ae0aea9e616b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: