Add InputDeviceIconSetSO configuration guide and related documentation
- Created a new markdown file detailing the configuration of InputDeviceIconSetSO. - Included sections on system architecture, field explanations, image specifications, and complete workflow from setup to runtime. - Documented the automatic device recognition logic and provided troubleshooting for common issues. - Added references to relevant files and scripts for easier navigation.
This commit is contained in:
467
Assets/_Game/Scripts/Editor/Input/InputDeviceIconSetSOEditor.cs
Normal file
467
Assets/_Game/Scripts/Editor/Input/InputDeviceIconSetSOEditor.cs
Normal file
@@ -0,0 +1,467 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEditor;
|
||||
using UnityEditor.UIElements;
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
using UnityEngine.UIElements;
|
||||
using BaseGames.UI;
|
||||
|
||||
namespace BaseGames.Editor.Input
|
||||
{
|
||||
/// <summary>
|
||||
/// InputDeviceIconSetSO 自定义 Inspector。
|
||||
///
|
||||
/// 布局(从上到下):
|
||||
/// ① 设备类型徽章 + 覆盖率芯片
|
||||
/// ② 操作按钮工具栏(从 Action Asset 自动填充 / 打开 Studio)
|
||||
/// ③ 按键图标条目表(Path | Icon Sprite | 48px 预览 | 删除按钮)
|
||||
/// ④ + 新增条目 按钮
|
||||
/// </summary>
|
||||
[CustomEditor(typeof(InputDeviceIconSetSO))]
|
||||
public class InputDeviceIconSetSOEditor : UnityEditor.Editor
|
||||
{
|
||||
private const string UssPath = "Assets/_Game/Scripts/Editor/UIToolkit/Editor.uss";
|
||||
|
||||
// 预设绑定路径快捷菜单(按设备类型)
|
||||
private static readonly Dictionary<InputDeviceType, string[]> s_CommonPaths = new()
|
||||
{
|
||||
[InputDeviceType.KeyboardMouse] = new[]
|
||||
{
|
||||
"<Keyboard>/e", "<Keyboard>/f", "<Keyboard>/r", "<Keyboard>/space",
|
||||
"<Keyboard>/enter", "<Keyboard>/escape", "<Keyboard>/shift",
|
||||
"<Keyboard>/ctrl", "<Keyboard>/tab", "<Keyboard>/q", "<Keyboard>/g",
|
||||
"<Mouse>/leftButton", "<Mouse>/rightButton", "<Mouse>/middleButton"
|
||||
},
|
||||
[InputDeviceType.XboxController] = new[]
|
||||
{
|
||||
"<Gamepad>/buttonSouth", "<Gamepad>/buttonNorth",
|
||||
"<Gamepad>/buttonEast", "<Gamepad>/buttonWest",
|
||||
"<Gamepad>/leftShoulder", "<Gamepad>/rightShoulder",
|
||||
"<Gamepad>/leftTrigger", "<Gamepad>/rightTrigger",
|
||||
"<Gamepad>/start", "<Gamepad>/select",
|
||||
"<Gamepad>/leftStickPress", "<Gamepad>/rightStickPress"
|
||||
},
|
||||
[InputDeviceType.PlayStationController] = new[]
|
||||
{
|
||||
"<Gamepad>/buttonSouth", "<Gamepad>/buttonNorth",
|
||||
"<Gamepad>/buttonEast", "<Gamepad>/buttonWest",
|
||||
"<Gamepad>/leftShoulder", "<Gamepad>/rightShoulder",
|
||||
"<Gamepad>/leftTrigger", "<Gamepad>/rightTrigger",
|
||||
"<Gamepad>/start", "<Gamepad>/select"
|
||||
},
|
||||
[InputDeviceType.SwitchController] = new[]
|
||||
{
|
||||
"<Gamepad>/buttonSouth", "<Gamepad>/buttonNorth",
|
||||
"<Gamepad>/buttonEast", "<Gamepad>/buttonWest",
|
||||
"<Gamepad>/leftShoulder", "<Gamepad>/rightShoulder",
|
||||
"<Gamepad>/leftTrigger", "<Gamepad>/rightTrigger",
|
||||
"<Gamepad>/start", "<Gamepad>/select"
|
||||
}
|
||||
};
|
||||
|
||||
// ── 状态 ──────────────────────────────────────────────────────────────
|
||||
private SerializedProperty _entriesProp;
|
||||
private VisualElement _root;
|
||||
private VisualElement _tableContainer;
|
||||
private Label _coverageLabel;
|
||||
|
||||
// ── 生命周期 ──────────────────────────────────────────────────────────
|
||||
public override VisualElement CreateInspectorGUI()
|
||||
{
|
||||
_entriesProp = serializedObject.FindProperty("_entries");
|
||||
|
||||
_root = new VisualElement();
|
||||
var uss = AssetDatabase.LoadAssetAtPath<StyleSheet>(UssPath);
|
||||
if (uss != null) _root.styleSheets.Add(uss);
|
||||
|
||||
BuildHeader();
|
||||
BuildToolbar();
|
||||
BuildTable();
|
||||
BuildAddButton();
|
||||
|
||||
return _root;
|
||||
}
|
||||
|
||||
// ── 头部 ─────────────────────────────────────────────────────────────
|
||||
|
||||
private void BuildHeader()
|
||||
{
|
||||
var header = new VisualElement();
|
||||
header.style.flexDirection = FlexDirection.Row;
|
||||
header.style.alignItems = Align.Center;
|
||||
header.style.paddingLeft = 4;
|
||||
header.style.paddingRight = 4;
|
||||
header.style.paddingTop = 6;
|
||||
header.style.paddingBottom = 6;
|
||||
header.style.borderBottomWidth = 1;
|
||||
header.style.borderBottomColor = new StyleColor(new Color(0.5f, 0.5f, 0.5f, 0.25f));
|
||||
header.style.marginBottom = 6;
|
||||
|
||||
// 设备类型字段
|
||||
var deviceProp = serializedObject.FindProperty("_deviceType");
|
||||
var deviceField = new PropertyField(deviceProp, "设备类型");
|
||||
deviceField.style.flexGrow = 1;
|
||||
deviceField.RegisterValueChangeCallback(_ => RefreshCoverage());
|
||||
header.Add(deviceField);
|
||||
|
||||
// 覆盖率徽章
|
||||
_coverageLabel = new Label();
|
||||
_coverageLabel.AddToClassList("status-chip--ok");
|
||||
_coverageLabel.style.fontSize = 10;
|
||||
_coverageLabel.style.marginLeft = 8;
|
||||
RefreshCoverage();
|
||||
header.Add(_coverageLabel);
|
||||
|
||||
_root.Add(header);
|
||||
}
|
||||
|
||||
// ── 工具栏 ────────────────────────────────────────────────────────────
|
||||
|
||||
private void BuildToolbar()
|
||||
{
|
||||
var bar = new VisualElement();
|
||||
bar.AddToClassList("editor-toolbar");
|
||||
bar.style.marginBottom = 6;
|
||||
|
||||
// 从 InputActionAsset 自动填充路径
|
||||
var btnAutoFill = new Button(AutoFillFromActionAsset)
|
||||
{
|
||||
text = "⬇ 从 Action Asset 填充路径",
|
||||
tooltip = "扫描 InputReaderSO,自动为该设备控制方案添加所有绑定路径(Sprite 留空需手动指定)"
|
||||
};
|
||||
btnAutoFill.AddToClassList("wizard-factory-btn");
|
||||
bar.Add(btnAutoFill);
|
||||
|
||||
// 清空 null Sprite 条目
|
||||
var btnClean = new Button(RemoveEmptySpriteEntries)
|
||||
{
|
||||
text = "🧹 清理空 Sprite",
|
||||
tooltip = "移除 Sprite 为空的条目"
|
||||
};
|
||||
bar.Add(btnClean);
|
||||
|
||||
// 打开 Studio
|
||||
var btnStudio = new Button(() => InputIconStudioWindow.Open())
|
||||
{
|
||||
text = "🎨 打开 Icon Studio",
|
||||
tooltip = "打开完整的按键图标管理工作台"
|
||||
};
|
||||
btnStudio.AddToClassList("wizard-jump-btn");
|
||||
btnStudio.style.marginLeft = 8;
|
||||
bar.Add(btnStudio);
|
||||
|
||||
_root.Add(bar);
|
||||
}
|
||||
|
||||
// ── 条目表 ────────────────────────────────────────────────────────────
|
||||
|
||||
private void BuildTable()
|
||||
{
|
||||
// 表头
|
||||
var tableHead = new VisualElement();
|
||||
tableHead.style.flexDirection = FlexDirection.Row;
|
||||
tableHead.style.paddingLeft = 4;
|
||||
tableHead.style.paddingRight = 4;
|
||||
tableHead.style.paddingTop = 3;
|
||||
tableHead.style.paddingBottom = 3;
|
||||
tableHead.style.borderBottomWidth = 1;
|
||||
tableHead.style.borderBottomColor = new StyleColor(new Color(0.5f, 0.5f, 0.5f, 0.3f));
|
||||
tableHead.style.backgroundColor = new StyleColor(new Color(0.5f, 0.5f, 0.5f, 0.08f));
|
||||
|
||||
AddHeaderCell(tableHead, "绑定路径", flexGrow: 1f);
|
||||
AddHeaderCell(tableHead, "图标", flexGrow: 0.8f);
|
||||
AddHeaderCell(tableHead, "预览", width: 52f);
|
||||
AddHeaderCell(tableHead, "", width: 24f);
|
||||
_root.Add(tableHead);
|
||||
|
||||
// 条目容器
|
||||
_tableContainer = new VisualElement();
|
||||
_root.Add(_tableContainer);
|
||||
|
||||
RebuildRows();
|
||||
}
|
||||
|
||||
private void RebuildRows()
|
||||
{
|
||||
_tableContainer.Clear();
|
||||
serializedObject.Update();
|
||||
|
||||
for (int i = 0; i < _entriesProp.arraySize; i++)
|
||||
_tableContainer.Add(BuildRow(i));
|
||||
|
||||
RefreshCoverage();
|
||||
}
|
||||
|
||||
private VisualElement BuildRow(int index)
|
||||
{
|
||||
var entryProp = _entriesProp.GetArrayElementAtIndex(index);
|
||||
var pathProp = entryProp.FindPropertyRelative("BindingPath");
|
||||
var iconProp = entryProp.FindPropertyRelative("Icon");
|
||||
|
||||
var row = new VisualElement();
|
||||
row.style.flexDirection = FlexDirection.Row;
|
||||
row.style.alignItems = Align.Center;
|
||||
row.style.paddingTop = 2;
|
||||
row.style.paddingBottom = 2;
|
||||
row.style.paddingLeft = 4;
|
||||
row.style.paddingRight = 4;
|
||||
row.style.borderBottomWidth = 1;
|
||||
row.style.borderBottomColor = new StyleColor(new Color(0.5f, 0.5f, 0.5f, 0.12f));
|
||||
row.AddToClassList("list-item");
|
||||
|
||||
// ── 绑定路径 ──────────────────────────────────────────────────
|
||||
var pathField = new TextField { value = pathProp.stringValue, isDelayed = true };
|
||||
pathField.style.flexGrow = 1;
|
||||
pathField.style.flexShrink = 1;
|
||||
pathField.style.fontSize = 10;
|
||||
pathField.RegisterValueChangedCallback(evt =>
|
||||
{
|
||||
serializedObject.Update();
|
||||
_entriesProp.GetArrayElementAtIndex(index)
|
||||
.FindPropertyRelative("BindingPath").stringValue = evt.newValue;
|
||||
serializedObject.ApplyModifiedProperties();
|
||||
RefreshCoverage();
|
||||
});
|
||||
|
||||
// 路径快捷菜单按钮
|
||||
var pathRow = new VisualElement();
|
||||
pathRow.style.flexDirection = FlexDirection.Row;
|
||||
pathRow.style.flexGrow = 1;
|
||||
pathRow.style.alignItems = Align.Center;
|
||||
pathField.style.flexGrow = 1;
|
||||
pathRow.Add(pathField);
|
||||
|
||||
var menuBtn = new Button(() =>
|
||||
{
|
||||
var menu = new GenericMenu();
|
||||
var so = target as InputDeviceIconSetSO;
|
||||
if (so != null && s_CommonPaths.TryGetValue(so.DeviceType, out var paths))
|
||||
{
|
||||
foreach (var p in paths)
|
||||
{
|
||||
var captured = p;
|
||||
menu.AddItem(new GUIContent(p), false, () =>
|
||||
{
|
||||
serializedObject.Update();
|
||||
_entriesProp.GetArrayElementAtIndex(index)
|
||||
.FindPropertyRelative("BindingPath").stringValue = captured;
|
||||
serializedObject.ApplyModifiedProperties();
|
||||
pathField.value = captured;
|
||||
RefreshCoverage();
|
||||
});
|
||||
}
|
||||
}
|
||||
menu.ShowAsContext();
|
||||
}) { text = "▾" };
|
||||
menuBtn.style.width = 20;
|
||||
menuBtn.style.height = 18;
|
||||
menuBtn.style.fontSize = 9;
|
||||
menuBtn.style.paddingLeft = menuBtn.style.paddingRight = 0;
|
||||
menuBtn.tooltip = "常用路径快捷选择";
|
||||
pathRow.Add(menuBtn);
|
||||
row.Add(pathRow);
|
||||
|
||||
// ── Icon Sprite ───────────────────────────────────────────────
|
||||
var iconField = new ObjectField { objectType = typeof(Sprite), allowSceneObjects = false };
|
||||
iconField.value = iconProp.objectReferenceValue as Sprite;
|
||||
iconField.style.flexGrow = 0.8f;
|
||||
iconField.style.marginLeft = 4;
|
||||
iconField.RegisterValueChangedCallback(evt =>
|
||||
{
|
||||
serializedObject.Update();
|
||||
_entriesProp.GetArrayElementAtIndex(index)
|
||||
.FindPropertyRelative("Icon").objectReferenceValue = evt.newValue;
|
||||
serializedObject.ApplyModifiedProperties();
|
||||
UpdatePreviewInRow(row, evt.newValue as Sprite);
|
||||
RefreshCoverage();
|
||||
});
|
||||
row.Add(iconField);
|
||||
|
||||
// ── 预览缩略图 ────────────────────────────────────────────────
|
||||
var preview = new Image { name = "icon-preview" };
|
||||
preview.style.width = 48;
|
||||
preview.style.height = 48;
|
||||
preview.style.flexShrink = 0;
|
||||
preview.style.marginLeft = 4;
|
||||
preview.style.borderTopLeftRadius = 3;
|
||||
preview.style.borderTopRightRadius = 3;
|
||||
preview.style.borderBottomLeftRadius = 3;
|
||||
preview.style.borderBottomRightRadius = 3;
|
||||
preview.style.backgroundColor = new StyleColor(new Color(0f, 0f, 0f, 0.25f));
|
||||
preview.scaleMode = ScaleMode.ScaleToFit;
|
||||
if (iconProp.objectReferenceValue is Sprite spr)
|
||||
preview.sprite = spr;
|
||||
row.Add(preview);
|
||||
|
||||
// ── 删除按钮 ──────────────────────────────────────────────────
|
||||
var delBtn = new Button(() =>
|
||||
{
|
||||
serializedObject.Update();
|
||||
_entriesProp.DeleteArrayElementAtIndex(index);
|
||||
serializedObject.ApplyModifiedProperties();
|
||||
RebuildRows();
|
||||
}) { text = "✕" };
|
||||
delBtn.style.width = 24;
|
||||
delBtn.style.height = 24;
|
||||
delBtn.style.marginLeft = 4;
|
||||
delBtn.style.flexShrink = 0;
|
||||
delBtn.AddToClassList("action-button--danger");
|
||||
delBtn.tooltip = "删除此条目";
|
||||
row.Add(delBtn);
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
private void BuildAddButton()
|
||||
{
|
||||
var addBtn = new Button(() =>
|
||||
{
|
||||
serializedObject.Update();
|
||||
_entriesProp.InsertArrayElementAtIndex(_entriesProp.arraySize);
|
||||
var newEntry = _entriesProp.GetArrayElementAtIndex(_entriesProp.arraySize - 1);
|
||||
newEntry.FindPropertyRelative("BindingPath").stringValue = string.Empty;
|
||||
newEntry.FindPropertyRelative("Icon").objectReferenceValue = null;
|
||||
serializedObject.ApplyModifiedProperties();
|
||||
RebuildRows();
|
||||
}) { text = "+ 新增条目" };
|
||||
addBtn.style.marginTop = 6;
|
||||
addBtn.style.marginLeft = 4;
|
||||
addBtn.style.marginBottom = 4;
|
||||
addBtn.AddToClassList("wizard-factory-btn");
|
||||
_root.Add(addBtn);
|
||||
}
|
||||
|
||||
// ── 自动填充 ──────────────────────────────────────────────────────────
|
||||
|
||||
private void AutoFillFromActionAsset()
|
||||
{
|
||||
var so = target as InputDeviceIconSetSO;
|
||||
if (so == null) return;
|
||||
|
||||
// 查找项目中的 InputReaderSO,获取 InputActionAsset
|
||||
var readerGuids = AssetDatabase.FindAssets("t:InputReaderSO");
|
||||
InputActionAsset actionAsset = null;
|
||||
foreach (var g in readerGuids)
|
||||
{
|
||||
var path = AssetDatabase.GUIDToAssetPath(g);
|
||||
var reader = AssetDatabase.LoadAssetAtPath<ScriptableObject>(path);
|
||||
if (reader == null) continue;
|
||||
var so2 = new SerializedObject(reader);
|
||||
var prop = so2.FindProperty("_inputActions");
|
||||
if (prop?.objectReferenceValue is InputActionAsset asset)
|
||||
{
|
||||
actionAsset = asset;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (actionAsset == null)
|
||||
{
|
||||
EditorUtility.DisplayDialog("未找到 InputActionAsset",
|
||||
"无法在项目中找到 InputReaderSO 或其引用的 InputActionAsset。\n请手动填写绑定路径。", "确定");
|
||||
return;
|
||||
}
|
||||
|
||||
string scheme = so.DeviceType == InputDeviceType.KeyboardMouse ? "Keyboard&Mouse" : "Gamepad";
|
||||
|
||||
serializedObject.Update();
|
||||
|
||||
// 收集已有路径,避免重复
|
||||
var existingPaths = new HashSet<string>();
|
||||
for (int i = 0; i < _entriesProp.arraySize; i++)
|
||||
{
|
||||
var p = _entriesProp.GetArrayElementAtIndex(i).FindPropertyRelative("BindingPath").stringValue;
|
||||
if (!string.IsNullOrEmpty(p)) existingPaths.Add(p);
|
||||
}
|
||||
|
||||
int added = 0;
|
||||
foreach (var action in actionAsset)
|
||||
{
|
||||
foreach (var binding in action.bindings)
|
||||
{
|
||||
if (binding.isComposite) continue;
|
||||
if (!string.IsNullOrEmpty(scheme)
|
||||
&& !string.IsNullOrEmpty(binding.groups)
|
||||
&& !binding.groups.Contains(scheme, System.StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
var effectivePath = binding.effectivePath;
|
||||
if (string.IsNullOrEmpty(effectivePath)) continue;
|
||||
if (existingPaths.Contains(effectivePath)) continue;
|
||||
|
||||
existingPaths.Add(effectivePath);
|
||||
_entriesProp.InsertArrayElementAtIndex(_entriesProp.arraySize);
|
||||
var newEntry = _entriesProp.GetArrayElementAtIndex(_entriesProp.arraySize - 1);
|
||||
newEntry.FindPropertyRelative("BindingPath").stringValue = effectivePath;
|
||||
newEntry.FindPropertyRelative("Icon").objectReferenceValue = null;
|
||||
added++;
|
||||
}
|
||||
}
|
||||
|
||||
serializedObject.ApplyModifiedProperties();
|
||||
RebuildRows();
|
||||
|
||||
if (added == 0)
|
||||
EditorUtility.DisplayDialog("填充完成", "所有路径已存在,无新增条目。", "确定");
|
||||
else
|
||||
Debug.Log($"[InputIconStudio] 自动填充了 {added} 个绑定路径(Sprite 需手动指定)。");
|
||||
}
|
||||
|
||||
private void RemoveEmptySpriteEntries()
|
||||
{
|
||||
serializedObject.Update();
|
||||
int removed = 0;
|
||||
for (int i = _entriesProp.arraySize - 1; i >= 0; i--)
|
||||
{
|
||||
var icon = _entriesProp.GetArrayElementAtIndex(i).FindPropertyRelative("Icon");
|
||||
if (icon.objectReferenceValue == null)
|
||||
{
|
||||
_entriesProp.DeleteArrayElementAtIndex(i);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
serializedObject.ApplyModifiedProperties();
|
||||
RebuildRows();
|
||||
Debug.Log($"[InputIconStudio] 清理了 {removed} 个空 Sprite 条目。");
|
||||
}
|
||||
|
||||
// ── 工具方法 ──────────────────────────────────────────────────────────
|
||||
|
||||
private void RefreshCoverage()
|
||||
{
|
||||
if (_coverageLabel == null || _entriesProp == null) return;
|
||||
serializedObject.Update();
|
||||
int total = _entriesProp.arraySize;
|
||||
int hasIcon = 0;
|
||||
for (int i = 0; i < total; i++)
|
||||
{
|
||||
if (_entriesProp.GetArrayElementAtIndex(i)
|
||||
.FindPropertyRelative("Icon").objectReferenceValue != null)
|
||||
hasIcon++;
|
||||
}
|
||||
_coverageLabel.text = $"覆盖: {hasIcon}/{total}";
|
||||
bool allOk = total > 0 && hasIcon == total;
|
||||
_coverageLabel.EnableInClassList("status-chip--ok", allOk);
|
||||
_coverageLabel.EnableInClassList("status-chip--missing", !allOk);
|
||||
}
|
||||
|
||||
private static void UpdatePreviewInRow(VisualElement row, Sprite sprite)
|
||||
{
|
||||
var preview = row.Q<Image>("icon-preview");
|
||||
if (preview == null) return;
|
||||
preview.sprite = sprite;
|
||||
}
|
||||
|
||||
private static void AddHeaderCell(VisualElement parent, string text, float? flexGrow = null, float? width = null)
|
||||
{
|
||||
var lbl = new Label(text);
|
||||
lbl.style.fontSize = 10;
|
||||
lbl.style.opacity = 0.6f;
|
||||
lbl.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
if (flexGrow.HasValue) lbl.style.flexGrow = flexGrow.Value;
|
||||
if (width.HasValue) lbl.style.width = width.Value;
|
||||
lbl.style.paddingRight = 4;
|
||||
parent.Add(lbl);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f5106bbd57a6fb242b364657a739a8b6
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
1095
Assets/_Game/Scripts/Editor/Input/InputIconStudioWindow.cs
Normal file
1095
Assets/_Game/Scripts/Editor/Input/InputIconStudioWindow.cs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f6534cbe9abbd954bb1ece4cbb2d7747
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user