- 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.
1096 lines
45 KiB
C#
1096 lines
45 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using UnityEditor;
|
||
using UnityEditor.UIElements;
|
||
using UnityEngine;
|
||
using UnityEngine.InputSystem;
|
||
using UnityEngine.UIElements;
|
||
using BaseGames.Editor;
|
||
using BaseGames.UI;
|
||
|
||
namespace BaseGames.Editor.Input
|
||
{
|
||
/// <summary>
|
||
/// 按键图标管理工作台(Input Icon Studio)。
|
||
///
|
||
/// 布局:
|
||
/// 工具栏 (InputReaderSO 选取 + 刷新按钮)
|
||
/// 设备标签栏 (键鼠 / Xbox / PlayStation / Switch)
|
||
/// TwoPaneSplitView:
|
||
/// 左列:Action 列表(带覆盖指示点 + 当前绑定路径预览)
|
||
/// 右列:
|
||
/// ① 当前 Action × 当前设备的绑定路径 + Sprite 字段
|
||
/// ② 48px 大图预览
|
||
/// ③ 所有设备快览行(4 行)
|
||
/// ④ 模拟 InteractPromptWidget 外观预览
|
||
///
|
||
/// 菜单:BaseGames / Input Icon Studio (priority=55)
|
||
/// </summary>
|
||
public class InputIconStudioWindow : EditorWindow
|
||
{
|
||
private const string UssPath = "Assets/_Game/Scripts/Editor/UIToolkit/Editor.uss";
|
||
private const string PrefDevice = "InputIconStudio.Device";
|
||
private const string PrefAction = "InputIconStudio.Action";
|
||
private const float ListWidth = 280f;
|
||
|
||
// ── 静态开口 ──────────────────────────────────────────────────────────
|
||
|
||
[MenuItem("BaseGames/Input Icon Studio", priority = 55)]
|
||
public static void Open()
|
||
{
|
||
var wnd = GetWindow<InputIconStudioWindow>();
|
||
wnd.titleContent = new GUIContent("Input Icon Studio",
|
||
EditorGUIUtility.IconContent("d_PreTextureRGB").image);
|
||
wnd.minSize = new Vector2(780, 480);
|
||
}
|
||
|
||
// ── 数据 ──────────────────────────────────────────────────────────────
|
||
|
||
private ScriptableObject _inputReaderAsset;
|
||
private InputActionAsset _actionAsset;
|
||
|
||
// 四套图标集 SO(每个设备一套,可为 null)
|
||
private InputDeviceIconSetSO _kbSet, _xboxSet, _psSet, _switchSet;
|
||
|
||
// 当前激活设备标签
|
||
private InputDeviceType _activeDevice = InputDeviceType.KeyboardMouse;
|
||
// 当前选中的 Action 名称
|
||
private string _selectedAction;
|
||
|
||
// 缓存所有 actions(仅 Gameplay map)
|
||
private readonly List<string> _actionNames = new();
|
||
|
||
// ── UI 节点引用 ───────────────────────────────────────────────────────
|
||
|
||
private VisualElement _listContainer;
|
||
private VisualElement _detailPanel;
|
||
private VisualElement _deviceTabBar;
|
||
|
||
// ── 生命周期 ──────────────────────────────────────────────────────────
|
||
|
||
public void CreateGUI()
|
||
{
|
||
var uss = AssetDatabase.LoadAssetAtPath<StyleSheet>(UssPath);
|
||
if (uss != null) rootVisualElement.styleSheets.Add(uss);
|
||
|
||
rootVisualElement.style.flexDirection = FlexDirection.Column;
|
||
rootVisualElement.style.flexGrow = 1;
|
||
|
||
// 恢复上次选中状态
|
||
_activeDevice = (InputDeviceType)EditorPrefs.GetInt(PrefDevice, 0);
|
||
_selectedAction = EditorPrefs.GetString(PrefAction, null);
|
||
|
||
BuildToolbar();
|
||
BuildDeviceTabBar();
|
||
BuildMainSplit();
|
||
|
||
LoadAssetsFromProject();
|
||
}
|
||
|
||
private void OnDisable()
|
||
{
|
||
EditorPrefs.SetInt(PrefDevice, (int)_activeDevice);
|
||
EditorPrefs.SetString(PrefAction, _selectedAction ?? string.Empty);
|
||
}
|
||
|
||
// ── 工具栏 ────────────────────────────────────────────────────────────
|
||
|
||
private void BuildToolbar()
|
||
{
|
||
var toolbar = new VisualElement();
|
||
toolbar.AddToClassList("editor-toolbar");
|
||
|
||
// 标题图标
|
||
var icon = EditorGUIUtility.IconContent("d_PreTextureRGB");
|
||
if (icon.image != null)
|
||
{
|
||
var iconImg = new Image { image = icon.image };
|
||
iconImg.style.width = 18;
|
||
iconImg.style.height = 18;
|
||
iconImg.style.marginRight = 6;
|
||
toolbar.Add(iconImg);
|
||
}
|
||
|
||
var title = new Label("INPUT ICON STUDIO");
|
||
title.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||
title.style.fontSize = 12;
|
||
title.style.opacity = 0.85f;
|
||
title.style.marginRight = 12;
|
||
toolbar.Add(title);
|
||
|
||
// InputReaderSO 选取器
|
||
var readerLabel = new Label("InputReaderSO:");
|
||
readerLabel.style.opacity = 0.65f;
|
||
readerLabel.style.marginRight = 4;
|
||
readerLabel.style.alignSelf = Align.Center;
|
||
toolbar.Add(readerLabel);
|
||
|
||
var readerField = new ObjectField
|
||
{
|
||
objectType = typeof(ScriptableObject),
|
||
allowSceneObjects = false,
|
||
value = _inputReaderAsset
|
||
};
|
||
readerField.style.width = 200;
|
||
readerField.style.flexShrink = 0;
|
||
readerField.RegisterValueChangedCallback(evt =>
|
||
{
|
||
_inputReaderAsset = evt.newValue as ScriptableObject;
|
||
ReloadActionAsset();
|
||
RebuildActionList();
|
||
RebuildDetail();
|
||
});
|
||
toolbar.Add(readerField);
|
||
|
||
// 弹性填充
|
||
var spacer = new VisualElement();
|
||
spacer.style.flexGrow = 1;
|
||
toolbar.Add(spacer);
|
||
|
||
// 刷新按钮
|
||
var btnRefresh = new Button(RefreshAll) { text = "⟳ 刷新" };
|
||
btnRefresh.tooltip = "重新扫描项目资产";
|
||
toolbar.Add(btnRefresh);
|
||
|
||
// 帮助按钮
|
||
var btnHelp = new Button(ShowHelp) { text = "?" };
|
||
btnHelp.style.width = 24;
|
||
btnHelp.tooltip = "使用说明";
|
||
toolbar.Add(btnHelp);
|
||
|
||
rootVisualElement.Add(toolbar);
|
||
}
|
||
|
||
// ── 设备标签栏 ────────────────────────────────────────────────────────
|
||
|
||
private void BuildDeviceTabBar()
|
||
{
|
||
_deviceTabBar = new VisualElement();
|
||
_deviceTabBar.AddToClassList("tab-bar");
|
||
_deviceTabBar.style.paddingLeft = 8;
|
||
_deviceTabBar.style.paddingRight = 8;
|
||
rootVisualElement.Add(_deviceTabBar);
|
||
RebuildDeviceTabs();
|
||
}
|
||
|
||
private void RebuildDeviceTabs()
|
||
{
|
||
_deviceTabBar.Clear();
|
||
|
||
var devices = new[]
|
||
{
|
||
(InputDeviceType.KeyboardMouse, "🖱 键鼠", "(W/A/S/D + Mouse)"),
|
||
(InputDeviceType.XboxController, "🎮 Xbox", "Xbox Controller"),
|
||
(InputDeviceType.PlayStationController,"🎮 PlayStation","DualShock / DualSense"),
|
||
(InputDeviceType.SwitchController, "🕹 Switch", "Pro Controller / Joy-Con"),
|
||
};
|
||
|
||
foreach (var (devType, label, hint) in devices)
|
||
{
|
||
var iconSet = GetIconSetForDevice(devType);
|
||
int total = GetEntryCount(iconSet);
|
||
int filled = GetFilledCount(iconSet);
|
||
|
||
// 覆盖率圆点颜色
|
||
var dot = total > 0 && filled == total ? "●" : (filled == 0 ? "○" : "◑");
|
||
var dotColor = total > 0 && filled == total
|
||
? new Color(0.3f, 0.85f, 0.45f)
|
||
: filled == 0
|
||
? new Color(0.8f, 0.3f, 0.3f)
|
||
: new Color(0.9f, 0.75f, 0.2f);
|
||
|
||
var btn = new Button(() => SelectDevice(devType));
|
||
btn.tooltip = hint;
|
||
btn.style.flexDirection = FlexDirection.Row;
|
||
btn.style.alignItems = Align.Center;
|
||
btn.AddToClassList("tab-button");
|
||
|
||
var dotLbl = new Label(dot);
|
||
dotLbl.style.color = new StyleColor(dotColor);
|
||
dotLbl.style.fontSize = 10;
|
||
dotLbl.style.marginRight = 4;
|
||
btn.Add(dotLbl);
|
||
|
||
var nameLbl = new Label(label);
|
||
btn.Add(nameLbl);
|
||
|
||
if (total > 0)
|
||
{
|
||
var countLbl = new Label($" {filled}/{total}");
|
||
countLbl.style.fontSize = 10;
|
||
countLbl.style.opacity = 0.55f;
|
||
btn.Add(countLbl);
|
||
}
|
||
|
||
if (devType == _activeDevice)
|
||
btn.AddToClassList("tab-button--active");
|
||
|
||
_deviceTabBar.Add(btn);
|
||
}
|
||
}
|
||
|
||
private void SelectDevice(InputDeviceType device)
|
||
{
|
||
_activeDevice = device;
|
||
RebuildDeviceTabs();
|
||
RebuildActionList();
|
||
RebuildDetail();
|
||
}
|
||
|
||
// ── 主分屏 ────────────────────────────────────────────────────────────
|
||
|
||
private void BuildMainSplit()
|
||
{
|
||
var split = new TwoPaneSplitView(0, ListWidth, TwoPaneSplitViewOrientation.Horizontal);
|
||
split.style.flexGrow = 1;
|
||
rootVisualElement.Add(split);
|
||
|
||
// ── 左:Action 列表 ────────────────────────────────────────────
|
||
var leftPane = new VisualElement();
|
||
leftPane.style.flexDirection = FlexDirection.Column;
|
||
split.Add(leftPane);
|
||
|
||
// 左列标题
|
||
var leftHeader = new VisualElement();
|
||
leftHeader.AddToClassList("pane-header");
|
||
leftHeader.style.flexDirection = FlexDirection.Row;
|
||
leftHeader.style.alignItems = Align.Center;
|
||
|
||
var leftTitle = new Label("Actions");
|
||
leftTitle.style.flexGrow = 1;
|
||
leftHeader.Add(leftTitle);
|
||
|
||
var btnSetAsset = new Button(PickIconSetForCurrentDevice)
|
||
{
|
||
text = "📂 指定图标集",
|
||
tooltip = "为当前设备绑定一个 InputDeviceIconSetSO 资产"
|
||
};
|
||
btnSetAsset.style.fontSize = 10;
|
||
btnSetAsset.style.height = 20;
|
||
leftHeader.Add(btnSetAsset);
|
||
leftPane.Add(leftHeader);
|
||
|
||
// 左列滚动区
|
||
var leftScroll = new ScrollView(ScrollViewMode.Vertical);
|
||
leftScroll.style.flexGrow = 1;
|
||
leftPane.Add(leftScroll);
|
||
|
||
_listContainer = new VisualElement();
|
||
_listContainer.style.paddingTop = 4;
|
||
_listContainer.style.paddingBottom = 4;
|
||
leftScroll.Add(_listContainer);
|
||
|
||
// ── 右:详情区 ─────────────────────────────────────────────────
|
||
var rightScroll = new ScrollView(ScrollViewMode.Vertical);
|
||
rightScroll.style.flexGrow = 1;
|
||
split.Add(rightScroll);
|
||
|
||
_detailPanel = new VisualElement();
|
||
_detailPanel.style.flexDirection = FlexDirection.Column;
|
||
_detailPanel.style.paddingBottom = 16;
|
||
rightScroll.Add(_detailPanel);
|
||
}
|
||
|
||
// ── Action 列表 ───────────────────────────────────────────────────────
|
||
|
||
private void RebuildActionList()
|
||
{
|
||
if (_listContainer == null) return;
|
||
_listContainer.Clear();
|
||
|
||
if (_actionNames.Count == 0)
|
||
{
|
||
var empty = new Label("未加载 Action Asset\n请在上方指定 InputReaderSO");
|
||
empty.style.opacity = 0.5f;
|
||
empty.style.marginTop = 20;
|
||
empty.style.whiteSpace = WhiteSpace.Normal;
|
||
empty.style.unityTextAlign = TextAnchor.MiddleCenter;
|
||
_listContainer.Add(empty);
|
||
return;
|
||
}
|
||
|
||
var iconSet = GetIconSetForDevice(_activeDevice);
|
||
|
||
foreach (var actionName in _actionNames)
|
||
{
|
||
var effectivePath = GetEffectivePath(actionName);
|
||
var sprite = iconSet?.GetIcon(effectivePath ?? string.Empty);
|
||
bool hasIcon = sprite != null;
|
||
|
||
var row = new Button(() =>
|
||
{
|
||
_selectedAction = actionName;
|
||
RebuildActionListSelection();
|
||
RebuildDetail();
|
||
});
|
||
row.style.flexDirection = FlexDirection.Row;
|
||
row.style.alignItems = Align.Center;
|
||
row.style.paddingLeft = 10;
|
||
row.style.paddingRight = 8;
|
||
row.style.paddingTop = 6;
|
||
row.style.paddingBottom = 6;
|
||
row.style.borderRadius(0);
|
||
row.style.borderWidth(0);
|
||
row.style.marginBottom = 1;
|
||
row.style.backgroundColor = _selectedAction == actionName
|
||
? new StyleColor(new Color(0.35f, 0.55f, 0.85f, 0.22f))
|
||
: new StyleColor(Color.clear);
|
||
|
||
// 覆盖指示点
|
||
var dot = new Label(hasIcon ? "●" : "○");
|
||
dot.style.color = new StyleColor(hasIcon ? new Color(0.3f, 0.85f, 0.4f) : new Color(0.75f, 0.3f, 0.3f));
|
||
dot.style.fontSize = 10;
|
||
dot.style.width = 14;
|
||
dot.style.flexShrink = 0;
|
||
row.Add(dot);
|
||
|
||
// Action 名称
|
||
var nameLbl = new Label(actionName);
|
||
nameLbl.style.flexGrow = 1;
|
||
nameLbl.style.fontSize = 11;
|
||
row.Add(nameLbl);
|
||
|
||
// 缩略绑定路径
|
||
if (!string.IsNullOrEmpty(effectivePath))
|
||
{
|
||
var pathLbl = new Label(ShortPath(effectivePath));
|
||
pathLbl.style.fontSize = 10;
|
||
pathLbl.style.opacity = 0.5f;
|
||
pathLbl.style.marginLeft = 4;
|
||
row.Add(pathLbl);
|
||
}
|
||
|
||
// 微型图标预览
|
||
if (sprite != null)
|
||
{
|
||
var thumb = new Image { sprite = sprite };
|
||
thumb.style.width = 18;
|
||
thumb.style.height = 18;
|
||
thumb.style.marginLeft = 4;
|
||
thumb.style.flexShrink = 0;
|
||
row.Add(thumb);
|
||
}
|
||
|
||
row.name = "action-row-" + actionName;
|
||
_listContainer.Add(row);
|
||
}
|
||
}
|
||
|
||
private void RebuildActionListSelection()
|
||
{
|
||
if (_listContainer == null) return;
|
||
foreach (var child in _listContainer.Children())
|
||
{
|
||
if (child is not Button btn) continue;
|
||
var isSelected = btn.name == "action-row-" + _selectedAction;
|
||
btn.style.backgroundColor = isSelected
|
||
? new StyleColor(new Color(0.35f, 0.55f, 0.85f, 0.22f))
|
||
: new StyleColor(Color.clear);
|
||
}
|
||
}
|
||
|
||
// ── 详情面板 ──────────────────────────────────────────────────────────
|
||
|
||
private void RebuildDetail()
|
||
{
|
||
if (_detailPanel == null) return;
|
||
_detailPanel.Clear();
|
||
|
||
if (string.IsNullOrEmpty(_selectedAction))
|
||
{
|
||
var placeholder = new Label("← 从左侧选择一个 Action");
|
||
placeholder.style.opacity = 0.4f;
|
||
placeholder.style.marginTop = 60;
|
||
placeholder.style.unityTextAlign = TextAnchor.MiddleCenter;
|
||
_detailPanel.Add(placeholder);
|
||
return;
|
||
}
|
||
|
||
// ① 标题栏
|
||
BuildDetailHeader();
|
||
// ② 当前设备 Mapping 编辑区
|
||
BuildCurrentDeviceMapping();
|
||
// ③ 所有设备快览
|
||
BuildAllDevicesOverview();
|
||
// ④ 交互提示模拟预览
|
||
BuildPromptPreview();
|
||
// ⑤ 图标集资产状态
|
||
BuildIconSetStatus();
|
||
}
|
||
|
||
private void BuildDetailHeader()
|
||
{
|
||
var header = new VisualElement();
|
||
header.style.flexDirection = FlexDirection.Row;
|
||
header.style.alignItems = Align.Center;
|
||
header.style.paddingLeft = 12;
|
||
header.style.paddingRight = 12;
|
||
header.style.paddingTop = 10;
|
||
header.style.paddingBottom = 10;
|
||
header.style.borderBottomWidth = 1;
|
||
header.style.borderBottomColor = new StyleColor(new Color(0.5f, 0.5f, 0.5f, 0.25f));
|
||
|
||
var actionLbl = new Label(_selectedAction);
|
||
actionLbl.style.fontSize = 15;
|
||
actionLbl.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||
actionLbl.style.flexGrow = 1;
|
||
header.Add(actionLbl);
|
||
|
||
// 当前设备徽章
|
||
var devBadge = MakeDeviceBadge(_activeDevice);
|
||
header.Add(devBadge);
|
||
|
||
_detailPanel.Add(header);
|
||
}
|
||
|
||
private void BuildCurrentDeviceMapping()
|
||
{
|
||
var section = MakeSectionContainer("绑定路径与图标");
|
||
|
||
var iconSet = GetIconSetForDevice(_activeDevice);
|
||
var effectivePath = GetEffectivePath(_selectedAction);
|
||
|
||
// ── 绑定路径预览 ──────────────────────────────────────────────
|
||
var pathRow = new VisualElement();
|
||
pathRow.style.flexDirection = FlexDirection.Row;
|
||
pathRow.style.alignItems = Align.Center;
|
||
pathRow.style.marginBottom = 8;
|
||
|
||
var pathLabel = new Label("绑定路径");
|
||
pathLabel.style.opacity = 0.65f;
|
||
pathLabel.style.width = 80;
|
||
pathLabel.style.flexShrink = 0;
|
||
pathRow.Add(pathLabel);
|
||
|
||
var pathValue = new Label(effectivePath ?? "(未检测到绑定)");
|
||
pathValue.style.fontSize = 11;
|
||
pathValue.style.flexGrow = 1;
|
||
pathValue.style.opacity = string.IsNullOrEmpty(effectivePath) ? 0.4f : 1f;
|
||
pathValue.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||
pathRow.Add(pathValue);
|
||
|
||
if (!string.IsNullOrEmpty(effectivePath))
|
||
{
|
||
var copyBtn = new Button(() => { GUIUtility.systemCopyBuffer = effectivePath; })
|
||
{ text = "复制", tooltip = "复制路径到剪贴板" };
|
||
copyBtn.style.height = 20;
|
||
copyBtn.style.fontSize = 10;
|
||
pathRow.Add(copyBtn);
|
||
}
|
||
section.Add(pathRow);
|
||
|
||
// ── 图标集 SO 字段 + 大图预览 ─────────────────────────────────
|
||
if (iconSet == null)
|
||
{
|
||
var warn = new HelpBox($"当前设备({DeviceLabel(_activeDevice)})尚未配置图标集 SO。\n" +
|
||
"请在工具栏 InputIconService 的 Inspector 中指定对应图标集,或点击左列「指定图标集」。",
|
||
HelpBoxMessageType.Warning);
|
||
warn.style.marginTop = 4;
|
||
section.Add(warn);
|
||
}
|
||
else
|
||
{
|
||
var iconEditRow = new VisualElement();
|
||
iconEditRow.style.flexDirection = FlexDirection.Row;
|
||
iconEditRow.style.alignItems = Align.Center;
|
||
|
||
// 图标 ObjectField
|
||
var existingSprite = string.IsNullOrEmpty(effectivePath)
|
||
? null
|
||
: iconSet.GetIcon(effectivePath);
|
||
|
||
var spriteField = new ObjectField("图标 Sprite")
|
||
{
|
||
objectType = typeof(Sprite),
|
||
allowSceneObjects = false,
|
||
value = existingSprite
|
||
};
|
||
spriteField.style.flexGrow = 1;
|
||
|
||
// 大图预览
|
||
var bigPreview = new Image();
|
||
bigPreview.style.width = 64;
|
||
bigPreview.style.height = 64;
|
||
bigPreview.style.marginLeft = 12;
|
||
bigPreview.style.flexShrink = 0;
|
||
bigPreview.style.borderRadius(4);
|
||
bigPreview.style.backgroundColor = new StyleColor(new Color(0f, 0f, 0f, 0.25f));
|
||
bigPreview.scaleMode = ScaleMode.ScaleToFit;
|
||
if (existingSprite != null) bigPreview.sprite = existingSprite;
|
||
|
||
spriteField.RegisterValueChangedCallback(evt =>
|
||
{
|
||
if (string.IsNullOrEmpty(effectivePath)) return;
|
||
SaveIconToSet(iconSet, effectivePath, evt.newValue as Sprite);
|
||
bigPreview.sprite = evt.newValue as Sprite;
|
||
RebuildDeviceTabs();
|
||
RebuildActionListSelection();
|
||
});
|
||
|
||
iconEditRow.Add(spriteField);
|
||
iconEditRow.Add(bigPreview);
|
||
section.Add(iconEditRow);
|
||
|
||
// 提示:若路径为空,说明该 Action 在此设备方案下无绑定
|
||
if (string.IsNullOrEmpty(effectivePath))
|
||
{
|
||
var note = new HelpBox("该 Action 在此控制方案下没有绑定。如有需要,请在 InputActionAsset 中添加对应的 Binding。",
|
||
HelpBoxMessageType.Info);
|
||
note.style.marginTop = 4;
|
||
section.Add(note);
|
||
}
|
||
}
|
||
|
||
_detailPanel.Add(section);
|
||
}
|
||
|
||
private void BuildAllDevicesOverview()
|
||
{
|
||
var section = MakeSectionContainer("所有设备快览");
|
||
|
||
var devices = new[]
|
||
{
|
||
InputDeviceType.KeyboardMouse,
|
||
InputDeviceType.XboxController,
|
||
InputDeviceType.PlayStationController,
|
||
InputDeviceType.SwitchController,
|
||
};
|
||
|
||
foreach (var devType in devices)
|
||
{
|
||
var iconSet = GetIconSetForDevice(devType);
|
||
var path = GetEffectivePath(_selectedAction);
|
||
var sprite = (iconSet != null && path != null) ? iconSet.GetIcon(path) : null;
|
||
|
||
var row = new VisualElement();
|
||
row.style.flexDirection = FlexDirection.Row;
|
||
row.style.alignItems = Align.Center;
|
||
row.style.paddingTop = 4;
|
||
row.style.paddingBottom = 4;
|
||
row.style.paddingLeft = 6;
|
||
row.style.paddingRight = 6;
|
||
row.style.borderBottomWidth = 1;
|
||
row.style.borderBottomColor = new StyleColor(new Color(0.5f, 0.5f, 0.5f, 0.12f));
|
||
|
||
// 设备徽章
|
||
var badge = MakeDeviceBadge(devType);
|
||
badge.style.width = 110;
|
||
badge.style.flexShrink = 0;
|
||
row.Add(badge);
|
||
|
||
// 绑定路径
|
||
var pathLbl = new Label(path != null ? ShortPath(path) : "—");
|
||
pathLbl.style.flexGrow = 1;
|
||
pathLbl.style.fontSize = 10;
|
||
pathLbl.style.opacity = path == null ? 0.4f : 1f;
|
||
row.Add(pathLbl);
|
||
|
||
// 状态点
|
||
var statusLbl = new Label(sprite != null ? "✓" : (iconSet == null ? "—" : "✗"));
|
||
statusLbl.style.color = new StyleColor(
|
||
sprite != null ? new Color(0.3f, 0.85f, 0.4f) :
|
||
iconSet == null ? new Color(0.6f, 0.6f, 0.6f) :
|
||
new Color(0.85f, 0.3f, 0.3f));
|
||
statusLbl.style.width = 20;
|
||
statusLbl.style.unityTextAlign = TextAnchor.MiddleCenter;
|
||
row.Add(statusLbl);
|
||
|
||
// 小图预览
|
||
var thumb = new Image();
|
||
thumb.style.width = 28;
|
||
thumb.style.height = 28;
|
||
thumb.style.marginLeft = 6;
|
||
thumb.style.flexShrink = 0;
|
||
thumb.style.borderRadius(3);
|
||
thumb.style.backgroundColor = new StyleColor(new Color(0f, 0f, 0f, 0.2f));
|
||
thumb.scaleMode = ScaleMode.ScaleToFit;
|
||
if (sprite != null) thumb.sprite = sprite;
|
||
row.Add(thumb);
|
||
|
||
section.Add(row);
|
||
}
|
||
|
||
_detailPanel.Add(section);
|
||
}
|
||
|
||
private void BuildPromptPreview()
|
||
{
|
||
var section = MakeSectionContainer("交互提示预览(模拟)");
|
||
|
||
var iconSet = GetIconSetForDevice(_activeDevice);
|
||
var effectivePath = GetEffectivePath(_selectedAction);
|
||
var sprite = (iconSet != null && effectivePath != null) ? iconSet.GetIcon(effectivePath) : null;
|
||
|
||
// 模拟 InteractPromptWidget 的外观
|
||
var mockup = new VisualElement();
|
||
mockup.style.flexDirection = FlexDirection.Row;
|
||
mockup.style.alignItems = Align.Center;
|
||
mockup.style.alignSelf = Align.FlexStart;
|
||
mockup.style.paddingTop = 8;
|
||
mockup.style.paddingBottom = 8;
|
||
mockup.style.paddingLeft = 12;
|
||
mockup.style.paddingRight = 14;
|
||
mockup.style.marginTop = 4;
|
||
mockup.style.marginLeft = 8;
|
||
mockup.style.borderRadius(6);
|
||
mockup.style.backgroundColor = new StyleColor(new Color(0.08f, 0.08f, 0.10f, 0.85f));
|
||
mockup.style.borderWidth(1);
|
||
mockup.style.borderColor(new Color(0.5f, 0.5f, 0.6f, 0.35f));
|
||
|
||
// 按键图标区域
|
||
var keyBox = new VisualElement();
|
||
keyBox.style.width = 32;
|
||
keyBox.style.height = 32;
|
||
keyBox.style.borderRadius(4);
|
||
keyBox.style.backgroundColor = new StyleColor(new Color(0.2f, 0.2f, 0.25f, 0.9f));
|
||
keyBox.style.borderWidth(1);
|
||
keyBox.style.borderColor(new Color(0.6f, 0.6f, 0.7f, 0.5f));
|
||
keyBox.style.justifyContent = Justify.Center;
|
||
keyBox.style.alignItems = Align.Center;
|
||
keyBox.style.marginRight = 10;
|
||
keyBox.style.flexShrink = 0;
|
||
|
||
if (sprite != null)
|
||
{
|
||
var keyImg = new Image { sprite = sprite };
|
||
keyImg.style.width = 24;
|
||
keyImg.style.height = 24;
|
||
keyImg.scaleMode = ScaleMode.ScaleToFit;
|
||
keyBox.Add(keyImg);
|
||
}
|
||
else
|
||
{
|
||
var missingLbl = new Label("?");
|
||
missingLbl.style.opacity = 0.4f;
|
||
missingLbl.style.unityTextAlign = TextAnchor.MiddleCenter;
|
||
keyBox.Add(missingLbl);
|
||
}
|
||
mockup.Add(keyBox);
|
||
|
||
// 文字区域
|
||
var textCol = new VisualElement();
|
||
textCol.style.flexDirection = FlexDirection.Column;
|
||
|
||
var actionText = new Label("交互"); // 模拟 LabelText
|
||
actionText.style.fontSize = 11;
|
||
actionText.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||
actionText.style.color = new StyleColor(Color.white);
|
||
textCol.Add(actionText);
|
||
|
||
if (!string.IsNullOrEmpty(effectivePath))
|
||
{
|
||
var pathTip = new Label(ShortPath(effectivePath));
|
||
pathTip.style.fontSize = 9;
|
||
pathTip.style.opacity = 0.5f;
|
||
pathTip.style.color = new StyleColor(Color.white);
|
||
textCol.Add(pathTip);
|
||
}
|
||
mockup.Add(textCol);
|
||
|
||
section.Add(mockup);
|
||
|
||
if (sprite == null)
|
||
{
|
||
var note = new Label(iconSet == null
|
||
? "ⓘ 当前设备未配置图标集,无法预览。"
|
||
: "ⓘ 该路径在图标集中未找到对应 Sprite,运行时图标区域将隐藏。");
|
||
note.style.fontSize = 10;
|
||
note.style.opacity = 0.55f;
|
||
note.style.marginTop = 4;
|
||
note.style.whiteSpace = WhiteSpace.Normal;
|
||
section.Add(note);
|
||
}
|
||
|
||
_detailPanel.Add(section);
|
||
}
|
||
|
||
private void BuildIconSetStatus()
|
||
{
|
||
var section = MakeSectionContainer("图标集资产状态");
|
||
|
||
var rows = new[]
|
||
{
|
||
(InputDeviceType.KeyboardMouse, "键鼠", _kbSet),
|
||
(InputDeviceType.XboxController, "Xbox", _xboxSet),
|
||
(InputDeviceType.PlayStationController,"PlayStation", _psSet),
|
||
(InputDeviceType.SwitchController, "Switch", _switchSet),
|
||
};
|
||
|
||
foreach (var (devType, label, set) in rows)
|
||
{
|
||
var row = new VisualElement();
|
||
row.style.flexDirection = FlexDirection.Row;
|
||
row.style.alignItems = Align.Center;
|
||
row.style.marginBottom = 3;
|
||
|
||
var badge = MakeDeviceBadge(devType);
|
||
badge.style.width = 100;
|
||
badge.style.flexShrink = 0;
|
||
row.Add(badge);
|
||
|
||
if (set != null)
|
||
{
|
||
int total = GetEntryCount(set);
|
||
int filled = GetFilledCount(set);
|
||
|
||
var statusChip = new Label($"{filled}/{total} 图标");
|
||
statusChip.AddToClassList(filled == total && total > 0 ? "status-chip--ok" : "status-chip--missing");
|
||
statusChip.style.fontSize = 10;
|
||
statusChip.style.marginLeft = 8;
|
||
row.Add(statusChip);
|
||
|
||
var pingBtn = new Button(() => { EditorGUIUtility.PingObject(set); Selection.activeObject = set; })
|
||
{ text = "定位", tooltip = $"在 Project 窗口中高亮 {set.name}" };
|
||
pingBtn.style.height = 18;
|
||
pingBtn.style.fontSize = 10;
|
||
pingBtn.style.marginLeft = 6;
|
||
row.Add(pingBtn);
|
||
}
|
||
else
|
||
{
|
||
var missingLbl = new Label("⚠ 未配置");
|
||
missingLbl.style.opacity = 0.6f;
|
||
missingLbl.style.fontSize = 10;
|
||
missingLbl.style.marginLeft = 8;
|
||
row.Add(missingLbl);
|
||
|
||
var createBtn = new Button(() => CreateIconSetForDevice(devType))
|
||
{ text = "+ 新建", tooltip = $"创建 {label} 图标集 SO 并放入项目" };
|
||
createBtn.style.height = 18;
|
||
createBtn.style.fontSize = 10;
|
||
createBtn.style.marginLeft = 6;
|
||
createBtn.AddToClassList("wizard-factory-btn");
|
||
row.Add(createBtn);
|
||
}
|
||
|
||
section.Add(row);
|
||
}
|
||
|
||
_detailPanel.Add(section);
|
||
}
|
||
|
||
// ── 加载项目资产 ──────────────────────────────────────────────────────
|
||
|
||
private void LoadAssetsFromProject()
|
||
{
|
||
// 找 InputReaderSO
|
||
if (_inputReaderAsset == null)
|
||
{
|
||
var guids = AssetDatabase.FindAssets("t:InputReaderSO");
|
||
if (guids.Length > 0)
|
||
_inputReaderAsset = AssetDatabase.LoadAssetAtPath<ScriptableObject>(
|
||
AssetDatabase.GUIDToAssetPath(guids[0]));
|
||
}
|
||
|
||
ReloadActionAsset();
|
||
LoadIconSets();
|
||
RebuildDeviceTabs();
|
||
RebuildActionList();
|
||
RebuildDetail();
|
||
}
|
||
|
||
private void ReloadActionAsset()
|
||
{
|
||
_actionAsset = null;
|
||
_actionNames.Clear();
|
||
|
||
if (_inputReaderAsset == null) return;
|
||
|
||
var so = new SerializedObject(_inputReaderAsset);
|
||
var prop = so.FindProperty("_inputActions");
|
||
if (prop?.objectReferenceValue is InputActionAsset asset)
|
||
{
|
||
_actionAsset = asset;
|
||
var map = asset.FindActionMap("Gameplay", throwIfNotFound: false);
|
||
if (map != null)
|
||
foreach (var action in map.actions)
|
||
_actionNames.Add(action.name);
|
||
}
|
||
}
|
||
|
||
private void LoadIconSets()
|
||
{
|
||
var sets = EditorScaffoldUtils.FindAllAssetsOfType<InputDeviceIconSetSO>();
|
||
_kbSet = _xboxSet = _psSet = _switchSet = null;
|
||
|
||
// 按 DeviceType 字段匹配图标集 SO
|
||
foreach (var set in sets)
|
||
{
|
||
switch (set.DeviceType)
|
||
{
|
||
case InputDeviceType.KeyboardMouse: _kbSet ??= set; break;
|
||
case InputDeviceType.XboxController: _xboxSet ??= set; break;
|
||
case InputDeviceType.PlayStationController:_psSet ??= set; break;
|
||
case InputDeviceType.SwitchController: _switchSet??= set; break;
|
||
}
|
||
}
|
||
}
|
||
|
||
private void RefreshAll()
|
||
{
|
||
LoadAssetsFromProject();
|
||
}
|
||
|
||
// ── 每设备绑定路径查询 ────────────────────────────────────────────────
|
||
|
||
private string GetEffectivePath(string actionName)
|
||
{
|
||
if (_actionAsset == null || string.IsNullOrEmpty(actionName)) return null;
|
||
|
||
var action = _actionAsset.FindAction(actionName, throwIfNotFound: false);
|
||
if (action == null) return null;
|
||
|
||
string scheme = _activeDevice == InputDeviceType.KeyboardMouse ? "Keyboard&Mouse" : "Gamepad";
|
||
|
||
foreach (var binding in action.bindings)
|
||
{
|
||
if (binding.isComposite) continue;
|
||
if (!string.IsNullOrEmpty(scheme)
|
||
&& !string.IsNullOrEmpty(binding.groups)
|
||
&& !binding.groups.Contains(scheme, StringComparison.OrdinalIgnoreCase))
|
||
continue;
|
||
if (!string.IsNullOrEmpty(binding.effectivePath))
|
||
return binding.effectivePath;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// ── 保存图标到 SO ─────────────────────────────────────────────────────
|
||
|
||
private static void SaveIconToSet(InputDeviceIconSetSO iconSet, string path, Sprite sprite)
|
||
{
|
||
var so = new SerializedObject(iconSet);
|
||
var entries = so.FindProperty("_entries");
|
||
|
||
// 查找已有相同路径的条目
|
||
int found = -1;
|
||
for (int i = 0; i < entries.arraySize; i++)
|
||
{
|
||
var p = entries.GetArrayElementAtIndex(i).FindPropertyRelative("BindingPath").stringValue;
|
||
if (string.Equals(p, path, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
found = i;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (found < 0)
|
||
{
|
||
// 新增
|
||
entries.InsertArrayElementAtIndex(entries.arraySize);
|
||
found = entries.arraySize - 1;
|
||
entries.GetArrayElementAtIndex(found).FindPropertyRelative("BindingPath").stringValue = path;
|
||
}
|
||
|
||
entries.GetArrayElementAtIndex(found).FindPropertyRelative("Icon").objectReferenceValue = sprite;
|
||
so.ApplyModifiedProperties();
|
||
EditorUtility.SetDirty(iconSet);
|
||
AssetDatabase.SaveAssets();
|
||
}
|
||
|
||
// ── 工具方法 ──────────────────────────────────────────────────────────
|
||
|
||
private InputDeviceIconSetSO GetIconSetForDevice(InputDeviceType device) => device switch
|
||
{
|
||
InputDeviceType.KeyboardMouse => _kbSet,
|
||
InputDeviceType.XboxController => _xboxSet,
|
||
InputDeviceType.PlayStationController => _psSet,
|
||
InputDeviceType.SwitchController => _switchSet,
|
||
_ => null,
|
||
};
|
||
|
||
private static int GetEntryCount(InputDeviceIconSetSO set)
|
||
{
|
||
if (set == null) return 0;
|
||
var so = new SerializedObject(set);
|
||
var arr = so.FindProperty("_entries");
|
||
return arr?.arraySize ?? 0;
|
||
}
|
||
|
||
private static int GetFilledCount(InputDeviceIconSetSO set)
|
||
{
|
||
if (set == null) return 0;
|
||
var so = new SerializedObject(set);
|
||
var arr = so.FindProperty("_entries");
|
||
if (arr == null) return 0;
|
||
int count = 0;
|
||
for (int i = 0; i < arr.arraySize; i++)
|
||
if (arr.GetArrayElementAtIndex(i).FindPropertyRelative("Icon").objectReferenceValue != null)
|
||
count++;
|
||
return count;
|
||
}
|
||
|
||
private void PickIconSetForCurrentDevice()
|
||
{
|
||
// 打开 Project 的 ObjectPicker 让用户选一个 InputDeviceIconSetSO
|
||
// 简单实现:扫描项目中所有 InputDeviceIconSetSO,弹出通用 GenericMenu
|
||
var all = EditorScaffoldUtils.FindAllAssetsOfType<InputDeviceIconSetSO>();
|
||
if (all.Count == 0)
|
||
{
|
||
EditorUtility.DisplayDialog("无图标集资产",
|
||
"项目中未找到 InputDeviceIconSetSO 资产,请先创建。", "确定");
|
||
return;
|
||
}
|
||
|
||
var menu = new GenericMenu();
|
||
foreach (var set in all)
|
||
{
|
||
var captured = set;
|
||
menu.AddItem(new GUIContent(set.name), false, () =>
|
||
{
|
||
AssignIconSetToDevice(_activeDevice, captured);
|
||
LoadIconSets();
|
||
RebuildDeviceTabs();
|
||
RebuildActionList();
|
||
RebuildDetail();
|
||
});
|
||
}
|
||
menu.AddSeparator("");
|
||
menu.AddItem(new GUIContent("新建..."), false, () => CreateIconSetForDevice(_activeDevice));
|
||
menu.ShowAsContext();
|
||
}
|
||
|
||
private void AssignIconSetToDevice(InputDeviceType device, InputDeviceIconSetSO set)
|
||
{
|
||
switch (device)
|
||
{
|
||
case InputDeviceType.KeyboardMouse: _kbSet = set; break;
|
||
case InputDeviceType.XboxController: _xboxSet = set; break;
|
||
case InputDeviceType.PlayStationController: _psSet = set; break;
|
||
case InputDeviceType.SwitchController: _switchSet = set; break;
|
||
}
|
||
}
|
||
|
||
private void CreateIconSetForDevice(InputDeviceType device)
|
||
{
|
||
string defaultName = $"ICN_{device}";
|
||
var asset = EditorScaffoldUtils.CreateSOAssetInteractive<InputDeviceIconSetSO>(
|
||
"Assets/_Game/Data/UI/InputIcons", defaultName);
|
||
if (asset != null)
|
||
{
|
||
var so = new SerializedObject(asset);
|
||
var prop = so.FindProperty("_deviceType");
|
||
if (prop != null) { prop.enumValueIndex = (int)device; so.ApplyModifiedProperties(); }
|
||
AssignIconSetToDevice(device, asset);
|
||
LoadIconSets();
|
||
RebuildDeviceTabs();
|
||
RebuildActionList();
|
||
RebuildDetail();
|
||
}
|
||
}
|
||
|
||
// ── UI 构建辅助 ───────────────────────────────────────────────────────
|
||
|
||
private static VisualElement MakeSectionContainer(string title)
|
||
{
|
||
var section = new VisualElement();
|
||
section.style.paddingLeft = 12;
|
||
section.style.paddingRight = 12;
|
||
section.style.paddingTop = 10;
|
||
section.style.paddingBottom = 10;
|
||
section.style.borderBottomWidth = 1;
|
||
section.style.borderBottomColor = new StyleColor(new Color(0.5f, 0.5f, 0.5f, 0.18f));
|
||
|
||
var hdr = new Label(title);
|
||
hdr.AddToClassList("section-header");
|
||
hdr.style.marginBottom = 6;
|
||
section.Add(hdr);
|
||
|
||
return section;
|
||
}
|
||
|
||
private static VisualElement MakeDeviceBadge(InputDeviceType device)
|
||
{
|
||
var (label, bg) = device switch
|
||
{
|
||
InputDeviceType.KeyboardMouse => ("键鼠", new Color(0.2f, 0.4f, 0.7f, 0.5f)),
|
||
InputDeviceType.XboxController => ("Xbox", new Color(0.1f, 0.5f, 0.15f, 0.5f)),
|
||
InputDeviceType.PlayStationController => ("PS", new Color(0.1f, 0.25f, 0.65f, 0.5f)),
|
||
InputDeviceType.SwitchController => ("Switch", new Color(0.75f, 0.15f, 0.2f, 0.5f)),
|
||
_ => ("Unknown", new Color(0.4f, 0.4f, 0.4f, 0.4f)),
|
||
};
|
||
|
||
var badge = new Label(label);
|
||
badge.style.fontSize = 10;
|
||
badge.style.paddingTop = 2;
|
||
badge.style.paddingBottom = 2;
|
||
badge.style.paddingLeft = 7;
|
||
badge.style.paddingRight = 7;
|
||
badge.style.borderRadius(4);
|
||
badge.style.backgroundColor = new StyleColor(bg);
|
||
badge.style.borderWidth(1);
|
||
badge.style.borderColor(new Color(bg.r + 0.15f, bg.g + 0.15f, bg.b + 0.15f, 0.6f));
|
||
badge.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||
return badge;
|
||
}
|
||
|
||
private static string DeviceLabel(InputDeviceType device) => device switch
|
||
{
|
||
InputDeviceType.KeyboardMouse => "键鼠",
|
||
InputDeviceType.XboxController => "Xbox",
|
||
InputDeviceType.PlayStationController => "PlayStation",
|
||
InputDeviceType.SwitchController => "Switch",
|
||
_ => device.ToString(),
|
||
};
|
||
|
||
/// <summary>将完整绑定路径缩短为可读格式(去掉设备前缀)。</summary>
|
||
private static string ShortPath(string path)
|
||
{
|
||
if (string.IsNullOrEmpty(path)) return path;
|
||
var slash = path.LastIndexOf('/');
|
||
return slash >= 0 ? path[(slash + 1)..] : path;
|
||
}
|
||
|
||
private static void ShowHelp()
|
||
{
|
||
EditorUtility.DisplayDialog("Input Icon Studio — 使用说明",
|
||
"1. 在顶部选取 InputReaderSO 资产(项目中有唯一实例则自动加载)\n" +
|
||
"2. 通过设备标签页切换键鼠/Xbox/PS/Switch\n" +
|
||
"3. 左侧列表显示所有 Gameplay Actions:\n" +
|
||
" ● 绿色 = 当前设备已配置图标\n" +
|
||
" ○ 红色 = 未配置\n" +
|
||
"4. 选中 Action 后在右侧指定 Sprite\n" +
|
||
"5. 修改实时保存到对应的 InputDeviceIconSetSO\n\n" +
|
||
"💡 在 Inspector 中可对 InputDeviceIconSetSO 使用「从 Action Asset 填充路径」\n" +
|
||
" 批量生成所有路径条目,再逐一指定 Sprite。",
|
||
"知道了");
|
||
}
|
||
}
|
||
|
||
// ── 样式扩展方法(限本文件使用)─────────────────────────────────────────
|
||
|
||
internal static class VisualElementStyleExtensions
|
||
{
|
||
internal static void borderRadius(this IStyle style, float r)
|
||
{
|
||
style.borderTopLeftRadius = r;
|
||
style.borderTopRightRadius = r;
|
||
style.borderBottomLeftRadius = r;
|
||
style.borderBottomRightRadius = r;
|
||
}
|
||
|
||
internal static void borderWidth(this IStyle style, float w)
|
||
{
|
||
style.borderLeftWidth = w;
|
||
style.borderRightWidth = w;
|
||
style.borderTopWidth = w;
|
||
style.borderBottomWidth = w;
|
||
}
|
||
|
||
internal static void borderColor(this IStyle style, Color c)
|
||
{
|
||
var sc = new StyleColor(c);
|
||
style.borderLeftColor = sc;
|
||
style.borderRightColor = sc;
|
||
style.borderTopColor = sc;
|
||
style.borderBottomColor = sc;
|
||
}
|
||
|
||
internal static void borderRadius(this Button btn, float r)
|
||
=> btn.style.borderRadius(r);
|
||
|
||
internal static void borderWidth(this Button btn, float w)
|
||
=> btn.style.borderWidth(w);
|
||
}
|
||
}
|