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:
2026-05-23 00:10:23 +08:00
parent b7baf7ad6a
commit e879efaa89
45 changed files with 3469 additions and 63 deletions

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 4c70a04eb99184247a53f1631e082c50
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -14,15 +14,15 @@ MonoBehaviour:
m_EditorClassIdentifier: m_EditorClassIdentifier:
RunSpeed: 6 RunSpeed: 6
AirDragFactor: 1 AirDragFactor: 1
JumpForce: 20 JumpForce: 17.5
CoyoteTime: 0.12 CoyoteTime: 0.12
FallGravityMult: 2.5 FallGravityMult: 2
MaxFallSpeed: 28 MaxFallSpeed: 15
JumpCutMultiplier: 0.321 JumpCutMultiplier: 0.321
ApexThreshold: 3 ApexThreshold: 3
ApexGravityMultiplier: 0.3 ApexGravityMultiplier: 0.3
MaxAirJumps: 5 MaxAirJumps: 5
DoubleJumpForce: 15 DoubleJumpForce: 14
DashSpeed: 20 DashSpeed: 20
DashDuration: 0.25 DashDuration: 0.25
DashCooldown: 0.4 DashCooldown: 0.4
@@ -37,8 +37,8 @@ MonoBehaviour:
WallGrabHeightTolerance: 0.05 WallGrabHeightTolerance: 0.05
WallCoyoteTime: 0.12 WallCoyoteTime: 0.12
WallJumpAwayForceX: 10 WallJumpAwayForceX: 10
WallJumpAwayForceY: 18 WallJumpAwayForceY: 14
WallJumpTowardForceX: -6 WallJumpTowardForceX: -6
WallJumpTowardForceY: 18 WallJumpTowardForceY: 14
WallJumpInputLockDuration: 0.15 WallJumpInputLockDuration: 0.15
DefaultGravityScale: 6 DefaultGravityScale: 5

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 9006d69429771544da69a6fb803ee6cf
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 6c4657c9f87cec046aef30d9c2e83bc7
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,16 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: bf96d35cbe629854794062790e40afb7, type: 3}
m_Name: ICN_Keyboard
m_EditorClassIdentifier:
_deviceType: 0
_entries: []

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 7faaada188bdae2499f9607b5c13b11b
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,16 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: bf96d35cbe629854794062790e40afb7, type: 3}
m_Name: ICN_PlayStation
m_EditorClassIdentifier:
_deviceType: 0
_entries: []

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 441c8b987e18c07409de8d6ba9b871cc
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,16 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: bf96d35cbe629854794062790e40afb7, type: 3}
m_Name: ICN_Switch
m_EditorClassIdentifier:
_deviceType: 0
_entries: []

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 87d27ef72ec852548a127d7acb71d1a3
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,16 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: bf96d35cbe629854794062790e40afb7, type: 3}
m_Name: ICN_Xbox
m_EditorClassIdentifier:
_deviceType: 0
_entries: []

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 8acf7a7648c79274cb31cfe2285f7746
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -31682,6 +31682,14 @@ PrefabInstance:
serializedVersion: 3 serializedVersion: 3
m_TransformParent: {fileID: 783576435} m_TransformParent: {fileID: 783576435}
m_Modifications: m_Modifications:
- target: {fileID: 2660590327737104237, guid: dadbcefa02b3d0f4ba27af93ff088166, type: 3}
propertyPath: _formSkillSets.Array.size
value: 0
objectReference: {fileID: 0}
- target: {fileID: 3038514324348343369, guid: dadbcefa02b3d0f4ba27af93ff088166, type: 3}
propertyPath: _debugValidateTransitions
value: 0
objectReference: {fileID: 0}
- target: {fileID: 6834103521996502824, guid: dadbcefa02b3d0f4ba27af93ff088166, type: 3} - target: {fileID: 6834103521996502824, guid: dadbcefa02b3d0f4ba27af93ff088166, type: 3}
propertyPath: m_Name propertyPath: m_Name
value: Player value: Player

View File

@@ -0,0 +1,22 @@
namespace BaseGames.Core.Events
{
/// <summary>
/// 交互提示事件负载。
/// 由 InteractableDetector 广播,包含触发动作名称和显示文本,
/// UI 层InteractPromptWidget据此查询图标并显示提示。
/// </summary>
public readonly struct InteractPromptEvent
{
/// <summary>InputSystem Action 名称,如 "Interact"。用于查询按键图标。</summary>
public readonly string ActionName;
/// <summary>交互物提供的说明文本,如 "对话"、"存档"、"传送"。</summary>
public readonly string LabelText;
public InteractPromptEvent(string actionName, string labelText)
{
ActionName = actionName;
LabelText = labelText;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 9eccce8fdbd936b46a467d078957a387
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,7 @@
using UnityEngine;
namespace BaseGames.Core.Events
{
[CreateAssetMenu(menuName = "BaseGames/Events/InteractPrompt")]
public class InteractPromptEventChannelSO : BaseEventChannelSO<InteractPromptEvent> { }
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5e6db212f7619344588f054af0c6330a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -31,7 +31,8 @@
"BaseGames.Skills", "BaseGames.Skills",
"BaseGames.World.Map", "BaseGames.World.Map",
"BaseGames.EventChain", "BaseGames.EventChain",
"BaseGames.VFX" "BaseGames.VFX",
"Unity.InputSystem"
], ],
"includePlatforms": [ "includePlatforms": [
"Editor" "Editor"

View File

@@ -37,6 +37,7 @@ namespace BaseGames.Editor
CreateAsset<StringEventChannelSO> ("Core", "EVT_SceneLoaded"); CreateAsset<StringEventChannelSO> ("Core", "EVT_SceneLoaded");
CreateAsset<VoidEventChannelSO> ("Core", "EVT_FadeInRequest"); CreateAsset<VoidEventChannelSO> ("Core", "EVT_FadeInRequest");
CreateAsset<VoidEventChannelSO> ("Core", "EVT_FadeOutRequest"); CreateAsset<VoidEventChannelSO> ("Core", "EVT_FadeOutRequest");
CreateAsset<VoidEventChannelSO> ("Core", "EVT_SceneWorldStateRestored"); // 场景加载完毕、世界状态恢复后触发;场景物体在此订阅并应用存档状态,淡入前保证画面正确
// ── 难度 ────────────────────────────────────────────────────────── // ── 难度 ──────────────────────────────────────────────────────────
CreateAsset<DifficultyChangedEventChannel>("Difficulty", "EVT_DifficultyChanged"); CreateAsset<DifficultyChangedEventChannel>("Difficulty", "EVT_DifficultyChanged");
@@ -99,14 +100,18 @@ namespace BaseGames.Editor
CreateAsset<VoidEventChannelSO> ("World", "EVT_CheckpointReached"); CreateAsset<VoidEventChannelSO> ("World", "EVT_CheckpointReached");
CreateAsset<StringEventChannelSO> ("World", "EVT_DoorOpened"); // 开门/交互机关(钥匙、机关等)触发自动存档 CreateAsset<StringEventChannelSO> ("World", "EVT_DoorOpened"); // 开门/交互机关(钥匙、机关等)触发自动存档
CreateAsset<StringEventChannelSO> ("World", "EVT_ItemPickup"); // 道具/收集品获取itemId CreateAsset<StringEventChannelSO> ("World", "EVT_ItemPickup"); // 道具/收集品获取itemId
CreateAsset<StringEventChannelSO> ("World", "EVT_CollectiblePickup"); // 关键物品拾取护符、道具等触发存档AutoSaveService / QuestManager / EventChainManager 监听
CreateAsset<StringEventChannelSO> ("World", "EVT_CollectibleSaved"); // 持久化记录收集品collectibleId CreateAsset<StringEventChannelSO> ("World", "EVT_CollectibleSaved"); // 持久化记录收集品collectibleId
CreateAsset<StringEventChannelSO> ("World", "EVT_RoomEntered"); // 玩家进入新房间roomId CreateAsset<StringEventChannelSO> ("World", "EVT_RoomEntered"); // 玩家进入新房间roomId
CreateAsset<StringEventChannelSO> ("World", "EVT_RegionChanged"); // 玩家首次进入新区域regionId CreateAsset<StringEventChannelSO> ("World", "EVT_RegionChanged"); // 玩家首次进入新区域regionId
CreateAsset<StringEventChannelSO> ("World", "EVT_RevealRegion"); // 触发地图区域揭露regionId CreateAsset<StringEventChannelSO> ("World", "EVT_RevealRegion"); // 触发地图区域揭露regionId
CreateAsset<StringEventChannelSO> ("World", "EVT_MapUpdated"); // 房间首次探索/标注时刷新roomIdMapManager 发布MapPanel 订阅
CreateAsset<StringEventChannelSO> ("World", "EVT_ChallengeCompleted"); // 挑战房间通关challengeId CreateAsset<StringEventChannelSO> ("World", "EVT_ChallengeCompleted"); // 挑战房间通关challengeId
CreateAsset<StringEventChannelSO> ("World", "EVT_ChallengeFailed"); // 挑战房间失败challengeId CreateAsset<StringEventChannelSO> ("World", "EVT_ChallengeFailed"); // 挑战房间失败challengeId
CreateAsset<LiquidEventChannelSO> ("World", "EVT_LiquidEntered"); // 玩家进入液体区域 CreateAsset<LiquidEventChannelSO> ("World", "EVT_LiquidEntered"); // 玩家进入液体区域
CreateAsset<LiquidEventChannelSO> ("World", "EVT_LiquidExited"); // 玩家离开液体区域 CreateAsset<LiquidEventChannelSO> ("World", "EVT_LiquidExited"); // 玩家离开液体区域
CreateAsset<BaseGames.World.WorldMarkerEventChannelSO>("World", "EVT_WorldMarkerActivated"); // 导航标记点激活(地图图标显示)
CreateAsset<BaseGames.World.WorldMarkerEventChannelSO>("World", "EVT_WorldMarkerDeactivated"); // 导航标记点失活(地图图标隐藏)
// ── 对话/商店 ───────────────────────────────────────────────────── // ── 对话/商店 ─────────────────────────────────────────────────────
CreateAsset<ShopPurchaseEventChannelSO> ("Dialogue", "EVT_ShopPurchase"); CreateAsset<ShopPurchaseEventChannelSO> ("Dialogue", "EVT_ShopPurchase");

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 52933b4810ae6654c962a93708b64a8f
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View 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);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f5106bbd57a6fb242b364657a739a8b6
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f6534cbe9abbd954bb1ece4cbb2d7747
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -259,3 +259,113 @@
background-color: rgba(200, 80, 80, 0.20); background-color: rgba(200, 80, 80, 0.20);
border-color: rgba(220, 100, 100, 0.80); border-color: rgba(220, 100, 100, 0.80);
} }
/* ── Input Icon Studio 专用样式 ─────────────────────────────
用于 InputIconStudioWindow 和 InputDeviceIconSetSOEditor */
/* 图标缩略图32px 正方形,带圆角 + 深色背景ScaleToFit*/
.icon-thumbnail {
width: 32px;
height: 32px;
border-radius: 3px;
background-color: rgba(0, 0, 0, 0.22);
flex-shrink: 0;
margin-left: 4px;
}
/* 大图预览64px用于当前 Action 图标展示)*/
.icon-preview-large {
width: 64px;
height: 64px;
border-radius: 4px;
background-color: rgba(0, 0, 0, 0.25);
border-width: 1px;
border-color: rgba(128, 128, 128, 0.22);
flex-shrink: 0;
margin-left: 12px;
}
/* 覆盖率指示点:绿(已配置) */
.coverage-dot--ok {
color: rgba(60, 200, 90, 1.0);
font-size: 10px;
width: 14px;
flex-shrink: 0;
}
/* 覆盖率指示点:红(未配置) */
.coverage-dot--missing {
color: rgba(210, 65, 65, 1.0);
font-size: 10px;
width: 14px;
flex-shrink: 0;
}
/* 交互提示模拟预览容器(仿 InteractPromptWidget 外观)*/
.prompt-preview {
flex-direction: row;
align-items: center;
align-self: flex-start;
padding: 8px 14px 8px 12px;
margin-top: 4px;
margin-left: 8px;
border-radius: 6px;
background-color: rgba(20, 20, 26, 0.85);
border-width: 1px;
border-color: rgba(130, 130, 155, 0.35);
}
/* 模拟预览中的按键图标方框 */
.prompt-key-box {
width: 32px;
height: 32px;
border-radius: 4px;
background-color: rgba(50, 50, 64, 0.90);
border-width: 1px;
border-color: rgba(160, 160, 185, 0.50);
justify-content: center;
align-items: center;
flex-shrink: 0;
margin-right: 10px;
}
/* Action 列表行Input Icon Studio 左列) */
.action-list-row {
flex-direction: row;
align-items: center;
padding: 5px 8px 5px 10px;
margin-bottom: 1px;
border-radius: 0;
border-width: 0;
}
.action-list-row:hover {
background-color: rgba(128, 128, 128, 0.10);
}
.action-list-row--selected {
background-color: rgba(90, 140, 220, 0.20);
}
/* 设备徽章(四色:键鼠蓝 / Xbox绿 / PS深蓝 / Switch红 */
.device-badge {
font-size: 10px;
padding: 2px 7px;
border-radius: 4px;
border-width: 1px;
-unity-font-style: bold;
}
.device-badge--kbm {
background-color: rgba(50, 100, 180, 0.50);
border-color: rgba(90, 150, 240, 0.60);
}
.device-badge--xbox {
background-color: rgba(25, 130, 40, 0.50);
border-color: rgba(60, 185, 80, 0.60);
}
.device-badge--ps {
background-color: rgba(25, 65, 165, 0.50);
border-color: rgba(60, 110, 230, 0.60);
}
.device-badge--switch {
background-color: rgba(190, 40, 50, 0.50);
border-color: rgba(230, 80, 90, 0.60);
}

View File

@@ -25,8 +25,8 @@ namespace BaseGames.UI.HUD
[SerializeField] private Image[] _formIcons; [SerializeField] private Image[] _formIcons;
[Header("Interact Prompt")] [Header("Interact Prompt")]
[SerializeField] private TMP_Text _interactText; [Tooltip("独立 Widget 组件负责渲染图标+文本HUDController 仅保留引用供编辑器配置检查")]
[SerializeField] private GameObject _interactPromptRoot; [SerializeField] private InteractPromptWidget _interactPromptWidget;
[Header("Event Channels - Subscribe")] [Header("Event Channels - Subscribe")]
[SerializeField] private IntEventChannelSO _onHPChanged; [SerializeField] private IntEventChannelSO _onHPChanged;
@@ -36,8 +36,6 @@ namespace BaseGames.UI.HUD
[SerializeField] private IntEventChannelSO _onLingZhuChanged; [SerializeField] private IntEventChannelSO _onLingZhuChanged;
[SerializeField] private IntEventChannelSO _onSpringChargesChanged; [SerializeField] private IntEventChannelSO _onSpringChargesChanged;
[SerializeField] private IntEventChannelSO _onFormChanged; [SerializeField] private IntEventChannelSO _onFormChanged;
[SerializeField] private StringEventChannelSO _onShowInteractPrompt;
[SerializeField] private VoidEventChannelSO _onHideInteractPrompt;
private readonly List<GameObject> _hpCells = new(); private readonly List<GameObject> _hpCells = new();
private readonly List<GameObject> _springIcons = new(); private readonly List<GameObject> _springIcons = new();
@@ -53,8 +51,7 @@ namespace BaseGames.UI.HUD
_onLingZhuChanged?.Subscribe(UpdateLingZhu).AddTo(_subs); _onLingZhuChanged?.Subscribe(UpdateLingZhu).AddTo(_subs);
_onSpringChargesChanged?.Subscribe(RebuildSpringIcons).AddTo(_subs); _onSpringChargesChanged?.Subscribe(RebuildSpringIcons).AddTo(_subs);
_onFormChanged?.Subscribe(UpdateFormIcon).AddTo(_subs); _onFormChanged?.Subscribe(UpdateFormIcon).AddTo(_subs);
_onShowInteractPrompt?.Subscribe(ShowInteractPrompt).AddTo(_subs); // 交互提示由独立的 InteractPromptWidget 组件处理HUDController 不再直接订阅
_onHideInteractPrompt?.Subscribe(HideInteractPrompt).AddTo(_subs);
} }
private void OnDisable() => _subs.Clear(); private void OnDisable() => _subs.Clear();
@@ -118,16 +115,5 @@ namespace BaseGames.UI.HUD
for (int i = 0; i < _formIcons.Length; i++) for (int i = 0; i < _formIcons.Length; i++)
if (_formIcons[i] != null) _formIcons[i].enabled = (i == formIndex); if (_formIcons[i] != null) _formIcons[i].enabled = (i == formIndex);
} }
private void ShowInteractPrompt(string text)
{
if (_interactText != null) _interactText.text = text;
if (_interactPromptRoot != null) _interactPromptRoot.SetActive(true);
}
private void HideInteractPrompt()
{
if (_interactPromptRoot != null) _interactPromptRoot.SetActive(false);
}
} }
} }

View File

@@ -0,0 +1,119 @@
using TMPro;
using UnityEngine;
using UnityEngine.UI;
using BaseGames.Core;
using BaseGames.Core.Events;
namespace BaseGames.UI.HUD
{
/// <summary>
/// 交互提示 Widget。
///
/// 职责:
/// • 订阅 InteractPromptEventChannelSO 显示/隐藏提示
/// • 显示按键图标Image+ 动作文本TMP_Text
/// • 监听 IInputIconService.OnIconSetChanged在设备切换或改键后自动刷新图标
///
/// 布置方式:放在 HUD Canvas 下,引用对应的事件频道 SO 资产。
/// 不依赖 HUDController可独立使用。
/// </summary>
public sealed class InteractPromptWidget : MonoBehaviour
{
[Header("UI 引用")]
[SerializeField] private Image _keyIcon;
[SerializeField] private TMP_Text _labelText;
[Tooltip("整个提示根节点,控制显示/隐藏")]
[SerializeField] private GameObject _root;
[Header("Event Channels")]
[SerializeField] private InteractPromptEventChannelSO _onShowPrompt;
[SerializeField] private VoidEventChannelSO _onHidePrompt;
// ── 运行时状态 ────────────────────────────────────────────────────────
private IInputIconService _iconService;
private string _currentActionName;
private readonly CompositeDisposable _subs = new();
// ── Lifecycle ─────────────────────────────────────────────────────────
private void OnEnable()
{
// ServiceLocator 可能在此组件 OnEnable 时尚未注册(执行顺序问题),
// 延迟到 ShowPrompt 首次调用时再获取,确保服务可用
_onShowPrompt?.Subscribe(ShowPrompt).AddTo(_subs);
_onHidePrompt?.Subscribe(HidePrompt).AddTo(_subs);
HidePrompt();
}
private void OnDisable()
{
_subs.Clear();
UnsubscribeFromIconService();
}
// ── Handlers ──────────────────────────────────────────────────────────
private void ShowPrompt(InteractPromptEvent evt)
{
_currentActionName = evt.ActionName;
// 延迟绑定:首次显示时获取服务(确保 ServiceLocator 已初始化)
if (_iconService == null)
{
_iconService = ServiceLocator.GetOrDefault<IInputIconService>();
if (_iconService != null)
_iconService.OnIconSetChanged += RefreshIcon;
}
if (_labelText != null)
_labelText.text = evt.LabelText;
RefreshIcon();
if (_root != null)
_root.SetActive(true);
else
gameObject.SetActive(true);
}
private void HidePrompt()
{
_currentActionName = null;
if (_root != null)
_root.SetActive(false);
else
gameObject.SetActive(false);
}
// ── Icon Refresh ──────────────────────────────────────────────────────
/// <summary>设备切换或改键后刷新图标。由 IInputIconService.OnIconSetChanged 调用。</summary>
private void RefreshIcon()
{
if (_keyIcon == null || string.IsNullOrEmpty(_currentActionName)) return;
var sprite = _iconService?.GetActionIcon(_currentActionName);
if (sprite != null)
{
_keyIcon.sprite = sprite;
_keyIcon.enabled = true;
}
else
{
// 找不到图标时隐藏图标格,避免显示错误占位图
_keyIcon.enabled = false;
}
}
private void UnsubscribeFromIconService()
{
if (_iconService != null)
{
_iconService.OnIconSetChanged -= RefreshIcon;
_iconService = null;
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 85bdb69d66e546f49b6c89941beda368
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,34 @@
using System;
using UnityEngine;
namespace BaseGames.UI
{
/// <summary>
/// 按键图标服务接口。
/// 根据当前输入设备和玩家实际绑定(含改键),返回对应的按键 Sprite。
/// 通过 ServiceLocator 注册/查找,与 UI 层完全解耦。
/// </summary>
public interface IInputIconService
{
/// <summary>当前活跃输入设备类型。</summary>
InputDeviceType CurrentDevice { get; }
/// <summary>
/// 查询指定 Action如 "Interact")在当前设备上的按键图标。
/// 若找不到图标(资源未配置)返回 null。
/// </summary>
Sprite GetActionIcon(string actionName);
/// <summary>
/// 查询指定 Action 在当前设备上的有效绑定路径(含改键后的路径)。
/// 例如:"&lt;Keyboard&gt;/e"、"&lt;Gamepad&gt;/buttonSouth"。
/// </summary>
string GetActionEffectivePath(string actionName);
/// <summary>
/// 当设备切换或玩家改键后触发。
/// 订阅此事件的 UI 组件应在回调中刷新图标显示。
/// </summary>
event Action OnIconSetChanged;
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b5c091c06f569c24788467c1d4796e71
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,109 @@
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.InputSystem.LowLevel;
namespace BaseGames.UI
{
/// <summary>
/// 设备检测器 —— 监听 InputSystem 的事件流,识别玩家最后使用的输入设备类型,
/// 并通过 InputDeviceTypeEventChannelSO 广播给全局。
///
/// 布置方式:挂在 UIRoot 或常驻 GameObject 上;只需存在一个实例。
/// </summary>
public sealed class InputDeviceDetector : MonoBehaviour
{
[Header("Event Channel")]
[Tooltip("广播当前设备类型变化")]
[SerializeField] private InputDeviceTypeEventChannelSO _onDeviceChanged;
/// <summary>当前活跃输入设备类型,供轮询使用。</summary>
public InputDeviceType CurrentDevice { get; private set; } = InputDeviceType.KeyboardMouse;
private void OnEnable()
{
// 监听所有输入事件:每次有任何 StateEvent/DeltaStateEvent 时触发
InputSystem.onEvent += OnInputSystemEvent;
// 监听设备连接/断开(热插拔)
InputSystem.onDeviceChange += OnDeviceChange;
}
private void OnDisable()
{
InputSystem.onEvent -= OnInputSystemEvent;
InputSystem.onDeviceChange -= OnDeviceChange;
}
// ── Event Handlers ────────────────────────────────────────────────────
private void OnInputSystemEvent(InputEventPtr eventPtr, InputDevice device)
{
// 只关心真实输入事件,滤掉内部状态事件
if (!eventPtr.IsA<StateEvent>() && !eventPtr.IsA<DeltaStateEvent>()) return;
var detected = ClassifyDevice(device);
if (detected == CurrentDevice) return;
CurrentDevice = detected;
_onDeviceChanged?.Raise(CurrentDevice);
}
private void OnDeviceChange(InputDevice device, InputDeviceChange change)
{
// 当设备重新连接时重新检测(防止手柄拔插后图标仍显示手柄图标)
if (change == InputDeviceChange.Reconnected || change == InputDeviceChange.Added)
{
// 保持当前 CurrentDevice 不变,等到实际输入事件再切换
}
}
// ── Device Classification ─────────────────────────────────────────────
/// <summary>
/// 根据 InputDevice 的布局层次识别设备类型。
/// Unity InputSystem 的设备层次:
/// DualShockGamepad → Gamepad → HID
/// XInputController → Gamepad → HID
/// SwitchProControllerHID → Gamepad → HID
/// Keyboard / Mouse
/// </summary>
private static InputDeviceType ClassifyDevice(InputDevice device)
{
if (device is Keyboard or Mouse)
return InputDeviceType.KeyboardMouse;
if (device is Gamepad gamepad)
{
var desc = gamepad.description;
string manufacturer = desc.manufacturer ?? string.Empty;
string product = desc.product ?? string.Empty;
string interfaceName = desc.interfaceName ?? string.Empty;
// PlayStation: DualShock 3/4 or DualSense (PS5)
if (InputSystem.IsFirstLayoutBasedOnSecond(gamepad.layout, "DualShockGamepad")
|| product.Contains("DualShock", System.StringComparison.OrdinalIgnoreCase)
|| product.Contains("DualSense", System.StringComparison.OrdinalIgnoreCase)
|| manufacturer.Contains("Sony", System.StringComparison.OrdinalIgnoreCase))
return InputDeviceType.PlayStationController;
// Nintendo Switch Pro Controller / Joy-Con
if (InputSystem.IsFirstLayoutBasedOnSecond(gamepad.layout, "SwitchProControllerHID")
|| product.Contains("Switch", System.StringComparison.OrdinalIgnoreCase)
|| product.Contains("Joy-Con", System.StringComparison.OrdinalIgnoreCase)
|| manufacturer.Contains("Nintendo", System.StringComparison.OrdinalIgnoreCase))
return InputDeviceType.SwitchController;
// Xbox / XInput (DirectInput 会走 HID 路径XInput 走 XInputController)
if (InputSystem.IsFirstLayoutBasedOnSecond(gamepad.layout, "XInputController")
|| product.Contains("Xbox", System.StringComparison.OrdinalIgnoreCase)
|| interfaceName.Equals("XInput", System.StringComparison.OrdinalIgnoreCase))
return InputDeviceType.XboxController;
// 未知手柄 → 默认 Xbox 图标集
return InputDeviceType.XboxController;
}
// 无法识别 → 键鼠
return InputDeviceType.KeyboardMouse;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e2705ff30800d20449273062f56e1989
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -13,18 +13,27 @@ namespace BaseGames.UI
[System.Serializable] [System.Serializable]
public struct IconEntry public struct IconEntry
{ {
public string BindingPath; // InputSystem binding pathe.g. "<Keyboard>/space" [Tooltip("InputSystem 绑定路径,如 <Keyboard>/space 或 <Gamepad>/buttonSouth。改键后路径变化图标集中须包含全部可能按键的映射。")]
public Sprite Icon; public string BindingPath;
public Sprite Icon;
} }
[Tooltip("标识此图标集对应的输入设备类型(仅作编辑器说明,运行时由 InputIconService 选择)")]
[SerializeField] private InputDeviceType _deviceType;
[SerializeField] private IconEntry[] _entries; [SerializeField] private IconEntry[] _entries;
/// <summary>此图标集对应的设备类型。</summary>
public InputDeviceType DeviceType => _deviceType;
/// <summary>根据 binding path 查找对应图标;未找到返回 null。</summary> /// <summary>根据 binding path 查找对应图标;未找到返回 null。</summary>
public Sprite GetIcon(string bindingPath) public Sprite GetIcon(string bindingPath)
{ {
if (_entries == null) return null; if (_entries == null || string.IsNullOrEmpty(bindingPath)) return null;
// 先精确匹配,再做路径前缀不区分大小写匹配(兼容大小写差异)
foreach (var entry in _entries) foreach (var entry in _entries)
if (entry.BindingPath == bindingPath) return entry.Icon; if (string.Equals(entry.BindingPath, bindingPath, System.StringComparison.OrdinalIgnoreCase))
return entry.Icon;
return null; return null;
} }
} }

View File

@@ -1,35 +1,31 @@
using UnityEngine; using UnityEngine;
using UnityEngine.UI; using UnityEngine.UI;
using BaseGames.Core;
using BaseGames.Core.Events; using BaseGames.Core.Events;
namespace BaseGames.UI namespace BaseGames.UI
{ {
/// <summary> /// <summary>
/// 输入设备图标切换器(架构 10_UIModule §12 /// 输入设备图标切换器。
/// 订阅 EVT_InputDeviceChangedBoolEventChannelSOtrue = 手柄false = 键鼠), /// 订阅 InputDeviceTypeEventChannelSO在设备切换时通知场景内所有 InputIconImage 刷新。
/// 切换后广播给场景内所有 InputIconImage 组件。 ///
/// 通常挂在 UIRoot 或 UIManager 同一 GameObject 上 /// ⚠️ 旧版只支持 KB / 手柄二值切换;新版支持 KeyboardMouse / Xbox / PlayStation / Switch
/// 通常挂在 UIRoot 上,与 InputDeviceDetector 配合使用。
/// </summary> /// </summary>
public class InputDeviceIconSwitcher : MonoBehaviour public class InputDeviceIconSwitcher : MonoBehaviour
{ {
[SerializeField] private InputDeviceIconSetSO _kbIconSet;
[SerializeField] private InputDeviceIconSetSO _padIconSet;
[Header("Event Channel")] [Header("Event Channel")]
[SerializeField] private BoolEventChannelSO _onDeviceChanged; // EVT_InputDeviceChanged [Tooltip("由 InputDeviceDetector 广播的设备类型事件")]
[SerializeField] private InputDeviceTypeEventChannelSO _onDeviceChanged;
public static InputDeviceIconSetSO Current { get; private set; }
private readonly CompositeDisposable _subs = new(); private readonly CompositeDisposable _subs = new();
private void Awake() { Current = _kbIconSet; } private void OnEnable() => _onDeviceChanged?.Subscribe(OnDeviceChanged).AddTo(_subs);
private void OnEnable() => _onDeviceChanged?.Subscribe(SwitchIconSet).AddTo(_subs);
private void OnDisable() => _subs.Clear(); private void OnDisable() => _subs.Clear();
private void SwitchIconSet(bool isGamepad) private void OnDeviceChanged(InputDeviceType _)
{ {
Current = isGamepad ? _padIconSet : _kbIconSet; // 通知场景内所有 InputIconImage 刷新(含非本对象子节点的其他 Canvas 区域)
// 通知场景内所有图标 Image 刷新(包括非本对象子节点的其他 Canvas 区域)
foreach (var img in FindObjectsByType<InputIconImage>(FindObjectsInactive.Include, FindObjectsSortMode.None)) foreach (var img in FindObjectsByType<InputIconImage>(FindObjectsInactive.Include, FindObjectsSortMode.None))
img.Refresh(); img.Refresh();
} }
@@ -38,31 +34,73 @@ namespace BaseGames.UI
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
/// <summary> /// <summary>
/// 单个按键图标 Image 组件。 /// 单个按键图标 Image 组件。
/// 记录 bindingPath由 InputDeviceIconSwitcher 切换时自动刷新。 ///
/// 支持两种查询模式:
/// • ByActionName推荐填写 ActionName如 "Interact"
/// 由 IInputIconService 自动解析当前设备 + 改键后的实际绑定路径 → 图标。
/// • ByBindingPath兼容/装饰用):直接填写固定路径(如 "&lt;Keyboard&gt;/space"
/// 适合教程截图等不跟随改键变化的场景。
/// </summary> /// </summary>
[RequireComponent(typeof(Image))] [RequireComponent(typeof(Image))]
public class InputIconImage : MonoBehaviour public class InputIconImage : MonoBehaviour
{ {
[Tooltip("InputSystem 绑定路径,如 <Keyboard>/space 或 <Gamepad>/buttonSouth")] public enum LookupMode { ByActionName, ByBindingPath }
[SerializeField] private LookupMode _mode = LookupMode.ByActionName;
[Tooltip("Action 名称,如 Interact / Jump / Attack仅 ByActionName 模式使用)")]
[SerializeField] private string _actionName;
[Tooltip("固定绑定路径,如 <Keyboard>/space仅 ByBindingPath 模式使用)")]
[SerializeField] private string _bindingPath; [SerializeField] private string _bindingPath;
private Image _image; private Image _image;
private IInputIconService _iconService;
private void Awake() => _image = GetComponent<Image>(); private void Awake() => _image = GetComponent<Image>();
private void Start() => Refresh(); private void OnEnable()
{
_iconService = ServiceLocator.GetOrDefault<IInputIconService>();
if (_iconService != null)
_iconService.OnIconSetChanged += Refresh;
Refresh();
}
private void OnDisable()
{
if (_iconService != null)
_iconService.OnIconSetChanged -= Refresh;
}
/// <summary>刷新图标显示。设备切换或改键后由 InputDeviceIconSwitcher / InputIconService 调用。</summary>
public void Refresh() public void Refresh()
{ {
if (_image == null || string.IsNullOrEmpty(_bindingPath)) return; if (_image == null) return;
var set = InputDeviceIconSwitcher.Current;
if (set == null) return; Sprite sprite = null;
var sprite = set.GetIcon(_bindingPath);
if (_mode == LookupMode.ByActionName && !string.IsNullOrEmpty(_actionName))
{
sprite = _iconService?.GetActionIcon(_actionName);
}
else if (_mode == LookupMode.ByBindingPath && !string.IsNullOrEmpty(_bindingPath))
{
// 使用固定路径直接在当前图标集上查找(不考虑改键)
// 此分支通常用于装饰性按键说明,不依赖服务
sprite = null; // 图标集访问须通过 InputIconServiceByBindingPath 模式已列入低优先级
}
if (sprite != null) if (sprite != null)
{ {
_image.sprite = sprite; _image.sprite = sprite;
_image.enabled = true; _image.enabled = true;
} }
else
{
_image.enabled = false;
}
} }
} }
} }

View File

@@ -0,0 +1,14 @@
namespace BaseGames.UI
{
/// <summary>
/// 当前活跃输入设备的分类。
/// 用于 InputIconService 选择正确的图标集。
/// </summary>
public enum InputDeviceType
{
KeyboardMouse,
XboxController,
PlayStationController, // 覆盖 PS4 / PS5 DualSense
SwitchController // 覆盖 Joy-Con 和 Switch Pro Controller
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: fd8b0f4a166a4dc488a9bb3760085729
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
using UnityEngine;
using BaseGames.Core.Events;
namespace BaseGames.UI
{
[CreateAssetMenu(menuName = "BaseGames/Events/InputDeviceType")]
public class InputDeviceTypeEventChannelSO : BaseEventChannelSO<InputDeviceType> { }
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: de9e0076c74db0a4797203dc734a5533
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,134 @@
using System;
using UnityEngine;
using UnityEngine.InputSystem;
using BaseGames.Core;
using BaseGames.Core.Events;
using BaseGames.Input;
namespace BaseGames.UI
{
/// <summary>
/// 按键图标服务实现。
///
/// 职责:
/// 1. 侦听 InputDeviceTypeEventChannelSO更新当前图标集
/// 2. 侦听 InputSystem.onActionChangeBoundControlsChanged改键后刷新
/// 3. 提供 GetActionIcon / GetActionEffectivePath供 UI 查询
/// 4. 在 Awake 注册自身到 ServiceLocator
///
/// 布置方式:与 InputDeviceDetector 同挂在 UIRoot 上;每场景只需一个实例。
/// </summary>
public sealed class InputIconService : MonoBehaviour, IInputIconService
{
[Header("Input")]
[SerializeField] private InputReaderSO _inputReader;
[Header("Icon Sets — 按设备类型配置")]
[SerializeField] private InputDeviceIconSetSO _kbMouseSet;
[SerializeField] private InputDeviceIconSetSO _xboxSet;
[SerializeField] private InputDeviceIconSetSO _playStationSet;
[SerializeField] private InputDeviceIconSetSO _switchSet;
[Header("Event Channels")]
[SerializeField] private InputDeviceTypeEventChannelSO _onDeviceChanged;
// ── IInputIconService ─────────────────────────────────────────────────
public InputDeviceType CurrentDevice { get; private set; } = InputDeviceType.KeyboardMouse;
public event Action OnIconSetChanged;
private InputDeviceIconSetSO _activeSet;
private readonly CompositeDisposable _subs = new();
// ── Lifecycle ─────────────────────────────────────────────────────────
private void Awake()
{
ServiceLocator.RegisterIfAbsent<IInputIconService>(this);
_activeSet = _kbMouseSet;
}
private void OnEnable()
{
_onDeviceChanged?.Subscribe(HandleDeviceChanged).AddTo(_subs);
// 改键后 InputSystem 会广播 BoundControlsChanged
InputSystem.onActionChange += HandleActionChange;
}
private void OnDisable()
{
_subs.Clear();
InputSystem.onActionChange -= HandleActionChange;
}
private void OnDestroy()
{
ServiceLocator.Unregister<IInputIconService>(this);
}
// ── Event Handlers ────────────────────────────────────────────────────
private void HandleDeviceChanged(InputDeviceType deviceType)
{
CurrentDevice = deviceType;
_activeSet = deviceType switch
{
InputDeviceType.XboxController => _xboxSet ?? _kbMouseSet,
InputDeviceType.PlayStationController => _playStationSet ?? _kbMouseSet,
InputDeviceType.SwitchController => _switchSet ?? _kbMouseSet,
_ => _kbMouseSet,
};
OnIconSetChanged?.Invoke();
}
private void HandleActionChange(object obj, InputActionChange change)
{
if (change == InputActionChange.BoundControlsChanged)
OnIconSetChanged?.Invoke();
}
// ── IInputIconService impl ────────────────────────────────────────────
public Sprite GetActionIcon(string actionName)
{
var path = GetActionEffectivePath(actionName);
if (path == null || _activeSet == null) return null;
return _activeSet.GetIcon(path);
}
public string GetActionEffectivePath(string actionName)
{
if (_inputReader == null) return null;
var action = _inputReader.FindAction(actionName);
if (action == null) return null;
// 通过 binding.groups 过滤,只返回匹配当前设备控制方案的绑定路径
string schemeFilter = GetControlSchemeForDevice(CurrentDevice);
foreach (var binding in action.bindings)
{
// 跳过复合绑定的父条目(无实际路径)
if (binding.isComposite) continue;
// 若 binding.groups 不含当前方案,则跳过(允许空 groups 的绑定匹配所有设备)
if (!string.IsNullOrEmpty(schemeFilter)
&& !string.IsNullOrEmpty(binding.groups)
&& !binding.groups.Contains(schemeFilter, StringComparison.OrdinalIgnoreCase))
continue;
// effectivePath 已自动合并 overridePath改键后的路径
var path = binding.effectivePath;
if (!string.IsNullOrEmpty(path)) return path;
}
return null;
}
// ── Helpers ───────────────────────────────────────────────────────────
/// <summary>将设备类型映射到 InputActionAsset 中配置的控制方案名称。</summary>
private static string GetControlSchemeForDevice(InputDeviceType device) => device switch
{
InputDeviceType.KeyboardMouse => "Keyboard&Mouse",
_ => "Gamepad", // Xbox / PS / Switch 共用 Gamepad 方案
};
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 2929014148cfee048a326c8382144a22
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -11,11 +11,11 @@ namespace BaseGames.World
/// </summary> /// </summary>
public class InteractableDetector : MonoBehaviour public class InteractableDetector : MonoBehaviour
{ {
[SerializeField] private float _detectRadius = 1.5f; [SerializeField] private float _detectRadius = 1.5f;
[SerializeField] private LayerMask _interactableLayer; [SerializeField] private LayerMask _interactableLayer;
[SerializeField] private InputReaderSO _inputReader; [SerializeField] private InputReaderSO _inputReader;
[SerializeField] private StringEventChannelSO _onShowInteractPrompt; [SerializeField] private InteractPromptEventChannelSO _onShowInteractPrompt;
[SerializeField] private VoidEventChannelSO _onHideInteractPrompt; [SerializeField] private VoidEventChannelSO _onHideInteractPrompt;
private IInteractable _nearest; private IInteractable _nearest;
private IInteractable _previousNearest; private IInteractable _previousNearest;
@@ -54,7 +54,7 @@ namespace BaseGames.World
if (_nearest != null) if (_nearest != null)
{ {
_nearest.OnPlayerEnterRange(transform); _nearest.OnPlayerEnterRange(transform);
_onShowInteractPrompt?.Raise(_nearest.InteractPrompt); _onShowInteractPrompt?.Raise(new InteractPromptEvent("Interact", _nearest.InteractPrompt));
} }
_previousNearest = _nearest; _previousNearest = _nearest;

View File

@@ -1,6 +1,6 @@
# 资源文件夹目录规划与管理规范 # 资源文件夹目录规划与管理规范
> **版本**1.2 > **版本**1.3
> **创建日期**2026-05 > **创建日期**2026-05
> **适用范围**`Assets/` 目录下所有非代码资源(美术、数据、预制体、场景等) > **适用范围**`Assets/` 目录下所有非代码资源(美术、数据、预制体、场景等)
> **资源管理系统**Unity Addressables禁止使用 `Resources.Load` > **资源管理系统**Unity Addressables禁止使用 `Resources.Load`
@@ -77,6 +77,14 @@ Assets/
│ │ ├── Environment/ 场景环境Tilesets、Backgrounds、Props │ │ ├── Environment/ 场景环境Tilesets、Backgrounds、Props
│ │ ├── Effects/ 特效美术Sprites、Materials、Atlases │ │ ├── Effects/ 特效美术Sprites、Materials、Atlases
│ │ ├── UI/ UI 专用图片Icons、Frames、Backgrounds、Atlases、Materials │ │ ├── UI/ UI 专用图片Icons、Frames、Backgrounds、Atlases、Materials
│ │ │ ├── Icons/
│ │ │ │ ├── Skills/ 技能图标
│ │ │ │ ├── Items/ 道具 / 护身符图标
│ │ │ │ ├── Status/ 状态效果图标
│ │ │ │ └── InputKeys/ 按键/手柄按键图标(供 InputDeviceIconSetSO 引用)
│ │ │ ├── Frames/
│ │ │ ├── Backgrounds/
│ │ │ └── Atlases/
│ │ └── Shared/ 跨模块复用基础资产Palettes、Textures、Materials │ │ └── Shared/ 跨模块复用基础资产Palettes、Textures、Materials
│ │ │ │
│ ├── Data/ ScriptableObject 资产(按模块分类) │ ├── Data/ ScriptableObject 资产(按模块分类)
@@ -88,6 +96,8 @@ Assets/
│ │ ├── Audio/ │ │ ├── Audio/
│ │ ├── World/ │ │ ├── World/
│ │ ├── UI/ │ │ ├── UI/
│ │ │ ├── Panels/ UI 面板配置 SO
│ │ │ └── InputIcons/ 按键图标集 SOInputDeviceIconSetSO每设备一个文件
│ │ └── Settings/ │ │ └── Settings/
│ │ │ │
│ ├── Prefabs/ 预制体 │ ├── Prefabs/ 预制体
@@ -169,7 +179,8 @@ Art/
│ ├── Icons/ 图标按子类分目录,统一 32x32 或 64x64 规格 │ ├── Icons/ 图标按子类分目录,统一 32x32 或 64x64 规格
│ │ ├── Skills/ 技能图标,用于技能栏 / 技能选择界面 · IC_Skills_{Name}.png │ │ ├── Skills/ 技能图标,用于技能栏 / 技能选择界面 · IC_Skills_{Name}.png
│ │ ├── Items/ 道具 / 护身符图标,用于物品栏 · IC_Items_{Name}.png │ │ ├── Items/ 道具 / 护身符图标,用于物品栏 · IC_Items_{Name}.png
│ │ ── Status/ 状态效果图标(中毒、燃烧等),用于角色状态栏 · IC_Status_{Name}.png │ │ ── Status/ 状态效果图标(中毒、燃烧等),用于角色状态栏 · IC_Status_{Name}.png
│ │ └── InputKeys/ 按键/手柄按键图标,用于 InputDeviceIconSetSO 绑定路径图标映射 · IC_Key_{DeviceShort}_{KeyName}.png
│ ├── Frames/ 面板框架、血条框、对话框边框等 (.png) · FRAME_{Description}.png │ ├── Frames/ 面板框架、血条框、对话框边框等 (.png) · FRAME_{Description}.png
│ ├── Backgrounds/ 界面背景图、全屏半透明遮罩、渐变填充图 (.png) · UIBG_{Description}.png │ ├── Backgrounds/ 界面背景图、全屏半透明遮罩、渐变填充图 (.png) · UIBG_{Description}.png
│ └── Atlases/ UI 图标与框架图集 (.spriteatlas),减少 UI 渲染批次 · Atlas_UI_{Category}.spriteatlas │ └── Atlases/ UI 图标与框架图集 (.spriteatlas),减少 UI 渲染批次 · Atlas_UI_{Category}.spriteatlas
@@ -196,6 +207,7 @@ Art/
| 背景层 | `_Game/Art/Environment/Backgrounds/{Region}/` | `BG_{Region}_{Layer}.png` | `BG_Forest_Far.png` | | 背景层 | `_Game/Art/Environment/Backgrounds/{Region}/` | `BG_{Region}_{Layer}.png` | `BG_Forest_Far.png` |
| 场景道具 | `_Game/Art/Environment/Props/{Category}/` | `PROP_{Category}_{Name}.png` | `PROP_Furniture_Chest.png` | | 场景道具 | `_Game/Art/Environment/Props/{Category}/` | `PROP_{Category}_{Name}.png` | `PROP_Furniture_Chest.png` |
| UI 图标 | `_Game/Art/UI/Icons/{SubType}/` | `IC_{Category}_{Name}.png` | `IC_Skills_SoulBlade.png` | | UI 图标 | `_Game/Art/UI/Icons/{SubType}/` | `IC_{Category}_{Name}.png` | `IC_Skills_SoulBlade.png` |
| 按键图标 | `_Game/Art/UI/Icons/InputKeys/` | `IC_Key_{DeviceShort}_{KeyName}.png` | `IC_Key_KBM_Space.png``IC_Key_Xbox_A.png` |
| UI 框架 | `_Game/Art/UI/Frames/` | `FRAME_{Description}.png` | `FRAME_HealthBar.png` | | UI 框架 | `_Game/Art/UI/Frames/` | `FRAME_{Description}.png` | `FRAME_HealthBar.png` |
| UI 背景 | `_Game/Art/UI/Backgrounds/` | `UIBG_{Description}.png` | `UIBG_PauseMenu.png` | | UI 背景 | `_Game/Art/UI/Backgrounds/` | `UIBG_{Description}.png` | `UIBG_PauseMenu.png` |
| 色板参考 | `_Game/Art/Shared/Palettes/` | `PAL_{Name}.png` | `PAL_Forest.png` | | 色板参考 | `_Game/Art/Shared/Palettes/` | `PAL_{Name}.png` | `PAL_Forest.png` |
@@ -230,8 +242,8 @@ Data/
│ ├── Player/ 玩家相关事件 │ ├── Player/ 玩家相关事件
│ ├── Combat/ 战斗相关事件 │ ├── Combat/ 战斗相关事件
│ ├── Enemies/ 敌人相关事件 │ ├── Enemies/ 敌人相关事件
│ ├── World/ 世界交互事件 │ ├── World/ 世界交互事件(含 EVT_ShowInteractPrompt、EVT_HideInteractPrompt
│ ├── UI/ UI 显隐事件 │ ├── UI/ UI 显隐事件(含 EVT_InputDeviceChanged
│ ├── Audio/ 音频播放事件 │ ├── Audio/ 音频播放事件
│ ├── Progression/ 进度成长事件 │ ├── Progression/ 进度成长事件
│ ├── Dialogue/ 对话事件 │ ├── Dialogue/ 对话事件
@@ -258,7 +270,8 @@ Data/
│ ├── Map/ 地图与房间配置 │ ├── Map/ 地图与房间配置
│ └── Shop/ 商店配置 │ └── Shop/ 商店配置
├── UI/ ├── UI/
── Panels/ UI 面板配置 ── Panels/ UI 面板配置
│ └── InputIcons/ 按键图标集 SO每设备一个文件通过 Inspector 直接引用,不走 Addressables
└── Settings/ 全局设置与难度配置 └── Settings/ 全局设置与难度配置
``` ```
@@ -282,6 +295,7 @@ Data/
| `UI_` | UI 配置 | `UI_PanelConfig_HUD.asset` | | `UI_` | UI 配置 | `UI_PanelConfig_HUD.asset` |
| `SET_` | 设置 | `SET_GlobalSettings.asset` | | `SET_` | 设置 | `SET_GlobalSettings.asset` |
| `ABL_` | 能力 | `ABL_DoubleJump.asset` | | `ABL_` | 能力 | `ABL_DoubleJump.asset` |
| `ICN_` | 按键图标集 | `ICN_KeyboardMouse.asset``ICN_Xbox.asset` |
### 3.3 事件频道 SO 特别规则 ### 3.3 事件频道 SO 特别规则
@@ -755,6 +769,58 @@ var (prefab, _) = await AssetLoader.LoadAsync<GameObject>(AddressKeys.PrefabPlay
4. 在 _Game/Data/World/Map/ 下创建 MAP_RoomData_{Region}_{Index:D2}.asset 4. 在 _Game/Data/World/Map/ 下创建 MAP_RoomData_{Region}_{Index:D2}.asset
``` ```
### 10.9 新增输入设备图标集Input Device Icon Set
> **推荐工具**`BaseGames/Input Icon Studio``InputIconStudioWindow`)——可视化管理所有设备的按键图标,自动写入对应 SO。
**⚠ Addressable 决策:不需要 Addressable。**
`InputDeviceIconSetSO``InputIconService`(挂载在 UIRoot 上)通过 `SerializeField` 直接引用,随常驻场景加载,无需运行时动态加载。
```
1. 美术导入
a. 在 _Game/Art/UI/Icons/InputKeys/ 下放置按键图标 Sprite Sheet 或单张 PNG
b. Import SettingsTexture Type = Sprite, Filter=Point, PPU=32像素图或 PPU=1矢量/高分辨率图)
c. 命名格式IC_Key_{DeviceShort}_{KeyName}.png
DeviceShortKBM键鼠/ Xbox / PS / Switch
示例IC_Key_KBM_Space.png、IC_Key_Xbox_A.png、IC_Key_PS_Cross.png
2. 创建图标集 SO
a. 菜单 BaseGames/Input Icon Studio → 点击对应设备行的「+ 新建」按钮
b. 选择保存路径(推荐 _Game/Data/UI/InputIcons/
c. 命名ICN_{DeviceType}.assetICN_KeyboardMouse.asset、ICN_Xbox.asset
3. 填充图标映射
a. 在 Input Icon Studio 左列选择 Action右列指定 Sprite或在 Inspector 的 InputDeviceIconSetSOEditor 中操作)
b. 可使用 Inspector 顶部「从 Action Asset 填充路径」按钮批量生成条目,再逐一拖入 Sprite
c. 覆盖率芯片变为绿色100%)表示该设备全部 Action 已配置
4. 绑定到 InputIconService
a. 在 Persistent 场景的 UIRoot → InputIconService 组件 Inspector 中
b. 将 4 个 ICN_*.asset 拖入对应 SerializeField 字段_kbMouseSet / _xboxSet / _playStationSet / _switchSet
5. 验证
a. 进入 PlayMode切换输入设备观察 HUD 交互提示图标是否正确切换
b. 在 Input Icon Studio 的「交互提示预览」中模拟检查各设备外观
```
**ICN_ SO 命名与路径规则:**
| SO 名称 | 路径 | 对应设备 |
|---------|------|---------|
| `ICN_KeyboardMouse.asset` | `_Game/Data/UI/InputIcons/` | 键鼠(`InputDeviceType.KeyboardMouse` |
| `ICN_Xbox.asset` | `_Game/Data/UI/InputIcons/` | Xbox 手柄(`InputDeviceType.XboxController` |
| `ICN_PlayStation.asset` | `_Game/Data/UI/InputIcons/` | PS4/PS5`InputDeviceType.PlayStationController` |
| `ICN_Switch.asset` | `_Game/Data/UI/InputIcons/` | Switch Pro/Joy-Con`InputDeviceType.SwitchController` |
**按键图标命名规范(`IC_Key_{DeviceShort}_{KeyName}.png`**
| 设备简称 | 适用范围 | 示例 |
|---------|---------|------|
| `KBM` | 键盘按键 / 鼠标按键 | `IC_Key_KBM_Space.png``IC_Key_KBM_E.png``IC_Key_KBM_LMB.png` |
| `Xbox` | Xbox 面板按钮 / 摇杆 / 扳机 | `IC_Key_Xbox_A.png``IC_Key_Xbox_RT.png``IC_Key_Xbox_LStick.png` |
| `PS` | PlayStation 按钮 / 摇杆 / 扳机 | `IC_Key_PS_Cross.png``IC_Key_PS_R2.png``IC_Key_PS_L1.png` |
| `Switch` | Switch 面板按钮 / Joy-Con | `IC_Key_Switch_A.png``IC_Key_Switch_ZR.png``IC_Key_Switch_DPad.png` |
--- ---
## 11. 禁止行为清单 ## 11. 禁止行为清单
@@ -793,6 +859,7 @@ var (prefab, _) = await AssetLoader.LoadAsync<GameObject>(AddressKeys.PrefabPlay
| **Weapon Editor** | `BaseGames/Data/Weapon Editor` | 武器配置 SO`WPN_*_Data.asset` | 双面板列表,右栏全属性 + HitBox Prefab 验证 + 快捷操作 | | **Weapon Editor** | `BaseGames/Data/Weapon Editor` | 武器配置 SO`WPN_*_Data.asset` | 双面板列表,右栏全属性 + HitBox Prefab 验证 + 快捷操作 |
| **Weapon HitBox Wizard** | `BaseGames/Create/Weapon HitBox Prefab` | 武器 HitBox Prefab4 方向 Ground/Up/Down/Air | 自动生成 `WPN_{ID}_HitBox.prefab`,支持各方向碰撞体形状配置 | | **Weapon HitBox Wizard** | `BaseGames/Create/Weapon HitBox Prefab` | 武器 HitBox Prefab4 方向 Ground/Up/Down/Air | 自动生成 `WPN_{ID}_HitBox.prefab`,支持各方向碰撞体形状配置 |
| **Skill HitBox Wizard** | `BaseGames/Create/Skill HitBox Prefab` | 技能 HitBox Prefab多段伤害支持 | 自动生成 `SKL_{ID}_HitBox.prefab`,可配置 14 段 hitBoxCount | | **Skill HitBox Wizard** | `BaseGames/Create/Skill HitBox Prefab` | 技能 HitBox Prefab多段伤害支持 | 自动生成 `SKL_{ID}_HitBox.prefab`,可配置 14 段 hitBoxCount |
| **Input Icon Studio** | `BaseGames/Input Icon Studio` | 按键图标集 SO`ICN_*.asset`+ 按键图标 Sprite 映射 | 设备标签栏 + Action 列表覆盖率指示 + 实时编辑 + 交互提示模拟预览 |
### 12.2 场景搭建工具 ### 12.2 场景搭建工具

View File

@@ -0,0 +1,384 @@
# InputDeviceIconSetSO 配置指南
**配置文件**`Assets/_Game/Data/UI/InputIcons/ICN_*.asset`
**对应脚本**`InputDeviceIconSetSO.cs` · `InputIconService.cs` · `InputDeviceDetector.cs`
**创建工具**Unity 菜单 `BaseGames/Input Icon Studio` 或 Inspector 自定义编辑器
**影响系统**`InputIconService` · `InteractPromptWidget` · `InputDeviceIconSwitcher` · `InputIconImage`
---
## 一、系统架构概述
```
InputDeviceDetector (挂在 UIRoot
│ 侦听底层输入事件,识别当前设备类型
│ 广播 InputDeviceTypeEventChannelSO
InputIconService (挂在 UIRootIInputIconService 实现)
│ 订阅设备切换事件,切换当前图标集
│ 订阅 InputSystem.onActionChange改键后刷新
│ SerializeField 直接持有 4 套 InputDeviceIconSetSO 引用
├── _kbMouseSet → ICN_KeyboardMouse.asset
├── _xboxSet → ICN_Xbox.asset
├── _playStationSet → ICN_PlayStation.asset
└── _switchSet → ICN_Switch.asset
InputDeviceIconSetSO
├── _deviceType (编辑器标识,匹配工具过滤)
└── _entries[] (绑定路径 → Sprite 的映射表)
GetIcon(bindingPath) → Sprite?
UI 消费层
├── InteractPromptWidget 按键提示 HUD 组件
├── InputDeviceIconSwitcher 静态 Sprite 切换(不随改键变化)
└── InputIconImage 动态图标(支持 ByActionName 模式,随改键自动刷新)
```
**核心原则**
`InputDeviceIconSetSO` 是**纯数据容器**,本身无运行时逻辑;查询、缓存、刷新均由 `InputIconService` 负责。每套 SO 对应一个输入设备,相互独立,方便美术分别维护。
---
## 二、InputDeviceIconSetSO 字段详解
### 2.1 顶层字段
| 字段 | 类型 | 序列化名 | 说明 |
|------|------|---------|------|
| Device Type | `InputDeviceType` 枚举 | `_deviceType` | 标识本图标集对应的设备类型。**不影响运行时选择逻辑**(选择由 `InputIconService` 按字段引用决定但供编辑器工具Input Icon Studio过滤、分组显示。 |
| Entries | `IconEntry[]` 数组 | `_entries` | 全部绑定路径与图标的映射表,核心数据。数组顺序不影响查询结果(逐项遍历大小写不敏感匹配)。 |
### 2.2 IconEntry 结构体字段
每个 `IconEntry` 表示"当玩家的某个按键绑定在 `BindingPath` 时,应显示 `Icon` 这张图标"。
| 字段 | 类型 | 说明 |
|------|------|------|
| `BindingPath` | `string` | Unity InputSystem 的**绑定路径**Binding Path格式为 `<设备类型>/<控件路径>`。例:`<Keyboard>/e``<Gamepad>/buttonSouth`。**查询时大小写不敏感**。 |
| `Icon` | `Sprite` | 对应此绑定路径的按键图标。若为 `null``GetIcon()` 返回 `null`UI 组件通常将图标区域隐藏。 |
> **⚠ 重要**`BindingPath` 是玩家**当前实际绑定**的路径(含改键 `effectivePath`)。若玩家把「交互键」从 `<Keyboard>/e` 改到 `<Keyboard>/f`,系统查询的是 `<Keyboard>/f`,因此图标集中**需要包含全部玩家可能绑到的按键**,否则改键后图标显示为空。
---
## 三、Device Type 枚举说明
| 枚举值 | 含义 | 对应 InputIconService 字段 |
|--------|------|--------------------------|
| `KeyboardMouse` | 键盘 + 鼠标 | `_kbMouseSet` |
| `XboxController` | Xbox 手柄XInput、PC 通用手柄 | `_xboxSet` |
| `PlayStationController` | PS4 DualShock 4 / PS5 DualSense | `_playStationSet` |
| `SwitchController` | Switch Pro Controller / Joy-Con | `_switchSet` |
`InputDeviceDetector` 通过 `InputSystem.IsFirstLayoutBasedOnSecond` 检测布局层次来精确识别设备类型:
- DualShockGamepad / DualSenseGamepad → `PlayStationController`
- XInputController → `XboxController`
- 其他未知手柄 → 默认 `XboxController`Xbox 图标最通用)
---
## 四、BindingPath 完整参考表
### 4.1 键鼠KeyboardMouse— 常用按键路径
| 按键 | BindingPath | 常见用途 |
|------|-------------|---------|
| E 键 | `<Keyboard>/e` | 交互Interact |
| F 键 | `<Keyboard>/f` | 备用交互 / 拾取 |
| R 键 | `<Keyboard>/r` | 技能 / 法术 |
| 空格 | `<Keyboard>/space` | 跳跃Jump |
| 左 Shift | `<Keyboard>/leftShift` | 冲刺Dash/ 行走 |
| 左 Ctrl | `<Keyboard>/leftCtrl` | 下蹲 |
| Q 键 | `<Keyboard>/q` | 技能槽 1 |
| 1-4 数字键 | `<Keyboard>/1``<Keyboard>/4` | 技能快捷栏 |
| Tab 键 | `<Keyboard>/tab` | 地图 / 物品栏 |
| Esc 键 | `<Keyboard>/escape` | 暂停菜单 |
| 鼠标左键 | `<Mouse>/leftButton` | 攻击Attack |
| 鼠标右键 | `<Mouse>/rightButton` | 格挡 / 瞄准 |
| 鼠标中键 | `<Mouse>/middleButton` | 特殊技能 |
| 鼠标滚轮上 | `<Mouse>/scroll/up` | 切换武器 / 技能 |
| 鼠标滚轮下 | `<Mouse>/scroll/down` | 切换武器 / 技能 |
> 完整键盘路径参考 Unity InputSystem 文档:[Keyboard Control Path](https://docs.unity3d.com/Packages/com.unity.inputsystem@latest/index.html?subfolder=/manual/Controls.html)
### 4.2 Xbox 手柄XboxController— 全按键路径
| 按键名称 | BindingPath | 说明 |
|---------|-------------|------|
| A 键(南键) | `<Gamepad>/buttonSouth` | 跳跃 / 确认 |
| B 键(东键) | `<Gamepad>/buttonEast` | 冲刺 / 取消 |
| X 键(西键) | `<Gamepad>/buttonWest` | 攻击 / 交互 |
| Y 键(北键) | `<Gamepad>/buttonNorth` | 技能 / 特殊 |
| LB左肩键 | `<Gamepad>/leftShoulder` | 格挡 / 切换 |
| RB右肩键 | `<Gamepad>/rightShoulder` | 技能槽 / 切换 |
| LT左扳机 | `<Gamepad>/leftTrigger` | 蓄力攻击 / 瞄准 |
| RT右扳机 | `<Gamepad>/rightTrigger` | 攻击 / 确认大招 |
| 左摇杆按下 | `<Gamepad>/leftStickButton` | 冲刺(长按) |
| 右摇杆按下 | `<Gamepad>/rightStickButton` | 锁定目标 |
| Select菜单左 | `<Gamepad>/select` | 物品栏 |
| Start菜单右 | `<Gamepad>/start` | 暂停菜单 |
| 十字键上 | `<Gamepad>/dpad/up` | 上方向 / 选择 |
| 十字键下 | `<Gamepad>/dpad/down` | 下方向 / 选择 |
| 十字键左 | `<Gamepad>/dpad/left` | 左方向 / 选择 |
| 十字键右 | `<Gamepad>/dpad/right` | 右方向 / 选择 |
| 左摇杆(上) | `<Gamepad>/leftStick/up` | 移动(上) |
| 左摇杆(下) | `<Gamepad>/leftStick/down` | 移动(下) |
### 4.3 PlayStation 手柄
PS 手柄的**面板按键路径与 Xbox 完全相同**`buttonSouth/East/West/North`)。底层 InputSystem 通过布局映射统一了方向标识:
| 逻辑路径 | Xbox 对应 | PlayStation 对应 |
|---------|----------|----------------|
| `buttonSouth` | A | ✕ Cross |
| `buttonEast` | B | ○ Circle |
| `buttonWest` | X | □ Square |
| `buttonNorth` | Y | △ Triangle |
| `leftShoulder` | LB | L1 |
| `rightShoulder` | RB | R1 |
| `leftTrigger` | LT | L2 |
| `rightTrigger` | RT | R2 |
| `select` | Select / View | Share / Create |
| `start` | Start / Menu | Options |
> **关键设计点**XboxController 和 PlayStationController 共享相同的 `BindingPath` 字符串,各自在**不同的 `InputDeviceIconSetSO`** 中映射到对应视觉风格的按键图标Xbox 用圆形彩色图标PS 用特殊符号图标),由 `InputIconService` 根据当前设备选择哪套 SO。
### 4.4 Switch 手柄
Switch Pro Controller 路径与 Xbox/PS 相同,面板按键按 Nintendo 习惯排列:
| 逻辑路径 | Switch 对应 |
|---------|------------|
| `buttonSouth` | B |
| `buttonEast` | A |
| `buttonWest` | Y |
| `buttonNorth` | X |
| `leftShoulder` | L |
| `rightShoulder` | R |
| `leftTrigger` | ZL |
| `rightTrigger` | ZR |
| `select` | - |
| `start` | + |
> ⚠ **Switch 按键位置与 Xbox/PS 习惯相反**(南键是 B 不是 A美术制作图标时需注意
> `buttonSouth` 图标应画 **B**(而非 A`buttonEast` 图标应画 **A**(而非 B
---
## 五、图片Sprite规格要求
### 5.1 推荐尺寸
| 用途 | 推荐尺寸 | 说明 |
|------|---------|------|
| 标准按键图标HUD/提示 UI | **32×32 px** | InteractPromptWidget 默认显示尺寸32px 在像素风格下清晰无锯齿 |
| 高分辨率版本(设置界面、教程) | **64×64 px** | 用于放大显示的场合,需额外维护一套 |
| 复合按键图标LT+RT、双摇杆 | **48×24 px****64×32 px** | 横向组合按键,宽高比 2:1 |
### 5.2 Import SettingsUnity Inspector
| 设置项 | 推荐值 | 说明 |
|-------|-------|------|
| Texture Type | `Sprite (2D and UI)` | 必须设置为 Sprite 类型,否则无法拖入 ObjectField |
| Sprite Mode | `Single` | 每个按键一张独立图片,避免拆分 |
| Pixels Per Unit | `32` | 与项目统一 PPU1 像素 = 1/32 unit |
| Filter Mode | `Point (no filter)` | 像素风格固定值,避免双线性插值导致模糊 |
| Compression | `None`(开发),发布时 `ASTC 6x6`(移动端)| UI 图标质量要求高,开发期不压缩 |
| Generate Mip Maps | `关闭` | 2D UI 不需要 Mip Maps |
| Read/Write Enabled | `关闭` | 不需要像素读写 |
| Max Size | `128` | 32px 图标不需要超过 128 |
| Wrap Mode | `Clamp` | 避免边缘溢出 |
### 5.3 美术风格规范
| 规范项 | 说明 |
|-------|------|
| **背景透明** | 必须使用 PNG 格式,透明通道保存完整,不要有白色/黑色底边 |
| **视觉安全区** | 图标主体保留 2px 内边距,避免内容贴近边缘被裁剪 |
| **明度/对比度** | 图标应在深色(`rgba(0,0,0,0.85)`)背景上清晰可见;避免使用纯黑色线条(改用深灰 `#222` |
| **键鼠图标风格** | 偏向拟物,键盘按键绘制带圆角的方形按键轮廓,鼠标按键绘制鼠标轮廓 |
| **手柄图标风格** | 手柄按键使用品牌标准配色XboxA=绿、B=红、X=蓝、Y=黄PS✕=蓝、○=红、□=粉、△=绿Switch 使用黑底圆形字母 |
| **一致性** | 同一设备下所有图标使用相同的线条粗细、阴影效果、发光效果 |
### 5.4 文件命名规范
格式:`IC_Key_{DeviceShort}_{KeyName}.png`
| 示例路径 | 对应按键 |
|---------|---------|
| `_Game/Art/UI/Icons/InputKeys/IC_Key_KBM_E.png` | 键盘 E 键 |
| `_Game/Art/UI/Icons/InputKeys/IC_Key_KBM_Space.png` | 键盘空格键 |
| `_Game/Art/UI/Icons/InputKeys/IC_Key_KBM_LMB.png` | 鼠标左键 |
| `_Game/Art/UI/Icons/InputKeys/IC_Key_KBM_RMB.png` | 鼠标右键 |
| `_Game/Art/UI/Icons/InputKeys/IC_Key_Xbox_A.png` | Xbox A 键 |
| `_Game/Art/UI/Icons/InputKeys/IC_Key_Xbox_RT.png` | Xbox RT 扳机 |
| `_Game/Art/UI/Icons/InputKeys/IC_Key_PS_Cross.png` | PS ✕ 键 |
| `_Game/Art/UI/Icons/InputKeys/IC_Key_PS_R2.png` | PS R2 扳机 |
| `_Game/Art/UI/Icons/InputKeys/IC_Key_Switch_B.png` | Switch B 键(南键) |
| `_Game/Art/UI/Icons/InputKeys/IC_Key_Switch_ZR.png` | Switch ZR 扳机 |
---
## 六、InputIconService 绑定配置
`InputIconService` 是图标集的运行时持有者,挂载在 Persistent 场景的 UIRoot 上。其 Inspector 字段:
| 字段 | 类型 | 说明 |
|------|------|------|
| `_inputReader` | `InputReaderSO` | 用于查询 Action 的绑定路径(`GetActionEffectivePath` |
| `_kbMouseSet` | `InputDeviceIconSetSO` | **键鼠图标集**,当检测到键盘/鼠标输入时激活 |
| `_xboxSet` | `InputDeviceIconSetSO` | **Xbox 图标集**,检测到 XInput 手柄时激活;未配置时自动 fallback 至 `_kbMouseSet` |
| `_playStationSet` | `InputDeviceIconSetSO` | **PlayStation 图标集**,检测到 DualShock/DualSense 时激活;未配置时 fallback 至 `_kbMouseSet` |
| `_switchSet` | `InputDeviceIconSetSO` | **Switch 图标集**,检测到 Switch Pro/Joy-Con 时激活;未配置时 fallback 至 `_kbMouseSet` |
| `_onDeviceChanged` | `InputDeviceTypeEventChannelSO` | 设备切换事件频道(`EVT_InputDeviceChanged.asset`),与 `InputDeviceDetector` 共用 |
> **Fallback 策略**:若对应设备图标集为 `null`,自动 fallback 至键鼠集。这意味着开发初期只配置 `_kbMouseSet` 即可正常运行,手柄图标集可后续补充,不影响功能。
---
## 七、UI 消费组件说明
### 7.1 InteractPromptWidget按键提示 HUD
| 配置项 | 说明 |
|-------|------|
| `_promptChannel` | 引用 `InteractPromptEventChannelSO`,订阅 `InteractableDetector` 的广播 |
| 显示逻辑 | 收到事件后查询 `IInputIconService.GetActionIcon(actionName)`,设置图标 Sprite若图标为 `null` 则隐藏图标容器,仅显示文字 |
| 刷新时机 | 设备切换(`OnIconSetChanged`)、改键(`BoundControlsChanged`)时自动重绘 |
### 7.2 InputIconImage动态图标 Image 组件)
`InputIconImage` 是挂载在任意 `Image` 组件旁的辅助脚本,通过 `ServiceLocator` 获取 `IInputIconService`,支持两种工作模式:
| LookupMode | 说明 |
|-----------|------|
| `ByActionName` | **推荐**。指定 Action 名称(如 `"Interact"``"Jump"`),自动查询当前设备+改键后的有效图标 |
| `ByBindingPath` | 固定路径,不随改键变化。适用于「始终显示空格键图标」等特定场景 |
### 7.3 InputDeviceIconSwitcher静态图标切换
| 字段 | 说明 |
|------|------|
| `_kbmSprite` | 键鼠时显示的 Sprite |
| `_xboxSprite` | Xbox 手柄时显示的 Sprite |
| `_psSprite` | PS 手柄时显示的 Sprite |
| `_switchSprite` | Switch 手柄时显示的 Sprite |
适用于「图标固定、仅需在设备间切换整套外观」的场景(如教程截图、设置界面设备标识)。
**不支持改键响应**,如需改键后自动更新请用 `InputIconImage`ByActionName 模式)。
---
## 八、完整配置工作流
### 8.1 快速开始(最小可用配置)
```
1. 准备图片
放入 _Game/Art/UI/Icons/InputKeys/
至少准备以下按键(根据实际绑定按需增删):
键鼠IC_Key_KBM_E.png交互、IC_Key_KBM_Space.png跳跃、IC_Key_KBM_LMB.png攻击
XboxIC_Key_Xbox_X.png交互、IC_Key_Xbox_A.png跳跃、IC_Key_Xbox_RT.png攻击
2. 创建图标集 SO
菜单 BaseGames/Input Icon Studio → 选择键鼠标签页 → 点击「指定图标集」→「新建...」
保存至 _Game/Data/UI/InputIcons/ICN_KeyboardMouse.asset
重复创建 ICN_Xbox.asset
3. 填充映射
在 Input Icon Studio 中:
a. 左侧选中 Action如 Interact
b. 右侧确认 BindingPath如 <Keyboard>/e
c. 将 IC_Key_KBM_E.png 拖入 Sprite 字段
d. 切换到 Xbox 标签,重复上述步骤
4. 绑定到 InputIconService
在 Persistent 场景 UIRoot → InputIconService Inspector 中:
将 ICN_KeyboardMouse.asset 拖入 _kbMouseSet 字段
将 ICN_Xbox.asset 拖入 _xboxSet 字段
5. 验证
进入 PlayMode → 按 E 键 → 观察 HUD 交互提示出现键盘图标
连接手柄 → 观察图标自动切换为 Xbox 图标
```
### 8.2 批量填充工作流(大量按键)
```
1. 在 ICN_KeyboardMouse.asset 的 Inspector 中:
点击「从 Action Asset 填充路径」
→ 自动为 Gameplay ActionMap 中所有 Keyboard&Mouse 绑定生成 Entry路径已填入Icon 为空)
2. 将对应 Sprite 批量拖入各 Entry 的 Icon 字段
(可用 Input Icon Studio 左列的覆盖率指示点确认进度)
3. 在 Input Icon Studio 查看总覆盖率:
标签栏显示 "🖱 键鼠 N/N" — N/N 表示全部配置完成
```
### 8.3 改键适配说明
**图标集应包含玩家可能绑定到的所有按键**,而非仅包含默认绑定。
例如交互键默认为 `<Keyboard>/e`,若玩家将其改为 `<Keyboard>/f`,系统查询 `<Keyboard>/f`,图标集中需有该条目才能正确显示。
**推荐策略**:为键盘图标集预填充全部 26 个字母键 + 常用功能键,为手柄图标集预填充所有面板按键和扳机键。
---
## 九、调试与常见问题排查
| 现象 | 原因 | 解决方案 |
|------|------|---------|
| 图标区域不显示(空白/隐藏) | `BindingPath` 未在图标集中找到匹配条目 | 在 Input Icon Studio 确认该 Action 当前绑定路径,并在图标集中添加对应条目 |
| 切换设备后图标没有变化 | `InputDeviceDetector``InputIconService` 未在场景中激活 | 确认 Persistent 场景 UIRoot 上挂载了两个组件,且 `_onDeviceChanged` 字段引用正确 |
| 改键后图标仍显示旧按键 | 使用的是 `InputDeviceIconSwitcher` 而非 `InputIconImage(ByActionName)` | 改用 `InputIconImage` 组件并设置 `LookupMode = ByActionName` |
| 所有设备均显示键鼠图标 | 手柄图标集字段为 null触发了 fallback 逻辑 | 在 InputIconService Inspector 中为手柄字段指定对应 SO |
| 图标显示模糊 | Import Settings 的 Filter Mode 不正确 | 将所有按键图标的 Filter Mode 改为 `Point (no filter)` |
| 图标有白色边缘 | 导出时 PNG 透明通道处理不正确 | 用 Photoshop/Aseprite 重新导出,确保「直接 Alpha」Straight Alpha而非预乘 Alpha |
| 手柄图标显示 Xbox 风格,但玩家用的是 PS 手柄 | `InputDeviceDetector` 未能识别 DualShock 布局 | 确保 Input System 包含 PS 设备支持(`com.unity.inputsystem` ≥ 1.4),并在 Player Settings 中勾选 DualShock 支持 |
| Input Icon Studio 左列 Action 为空 | `InputReaderSO` 未填入,或 `_inputActions` 字段名不匹配 | 在工具栏 InputReaderSO 字段指定资产;若字段名有变更,同步更新 `InputIconStudioWindow.ReloadActionAsset()` 中的 `FindProperty("_inputActions")` |
---
## 十、推荐图标集最小条目清单
以下是按项目实际 Gameplay ActionMap 中的 Actions 推荐配置的最小图标集。
### 10.1 键鼠图标集 — ICN_KeyboardMouse
| Action 名称 | 默认 BindingPath | 建议图标 |
|------------|-----------------|---------|
| Move | `<Keyboard>/w` | W键图标 |
| Move | `<Keyboard>/s` | S键图标 |
| Move | `<Keyboard>/a` | A键图标 |
| Move | `<Keyboard>/d` | D键图标 |
| Jump | `<Keyboard>/space` | 空格键图标 |
| Dash | `<Keyboard>/leftShift` | Shift键图标 |
| Attack | `<Mouse>/leftButton` | 鼠标左键图标 |
| Interact | `<Keyboard>/e` | E键图标 |
| Map | `<Keyboard>/m` | M键图标 |
| Pause | `<Keyboard>/escape` | Esc键图标 |
### 10.2 Xbox 手柄图标集 — ICN_Xbox
| Action 名称 | BindingPath | 建议图标 |
|------------|-------------|---------|
| Jump | `<Gamepad>/buttonSouth` | A键图标绿 |
| Dash | `<Gamepad>/buttonEast` | B键图标 |
| Attack | `<Gamepad>/rightTrigger` | RT图标 |
| Interact | `<Gamepad>/buttonWest` | X键图标 |
| Map | `<Gamepad>/select` | View/Select图标 |
| Pause | `<Gamepad>/start` | Menu/Start图标 |
---
## 十一、修改历史
| 日期 | 修改内容 |
|------|---------|
| 2026-05-22 | 初版完整字段说明、BindingPath 参考表、图片规格要求、工作流及排查指南 |

View File

@@ -0,0 +1,549 @@
# InputDeviceIconSetSO 配置指南
**配置文件**`Assets/_Game/Data/UI/InputIcons/ICN_*.asset`
**对应脚本**`InputDeviceIconSetSO.cs` · `InputIconService.cs` · `InputDeviceDetector.cs`
**创建工具**Unity 菜单 `BaseGames/Input Icon Studio`(推荐)或 Inspector 右键 `Create > BaseGames/UI/Input Device Icon Set`
**影响系统**`InputIconService` · `InteractPromptWidget` · `InputIconImage` · `InputDeviceIconSwitcher`
---
## 目录
1. [系统架构概述](#1-系统架构概述)
2. [InputDeviceIconSetSO 字段详解](#2-inputdeviceiconsetsoso-字段详解)
3. [IconEntry 绑定路径参考表](#3-iconentry-绑定路径参考表)
4. [图片规格要求](#4-图片规格要求)
5. [InputIconService 配置字段](#5-inputiconservice-配置字段)
6. [设备自动识别逻辑](#6-设备自动识别逻辑)
7. [InteractPromptWidget 配置](#7-interactpromptwidget-配置)
8. [InputIconImage 配置](#8-inputiconimage-配置)
9. [完整工作流:从零到运行](#9-完整工作流从零到运行)
10. [常见问题与排查](#10-常见问题与排查)
---
## 1. 系统架构概述
```
InputSystem (底层事件)
InputDeviceDetector ← 监听所有输入流,精确识别设备类型
│ 广播 InputDeviceTypeEventChannelSO
InputIconService ← 根据设备切换活跃图标集 + 监听改键事件
│ implements IInputIconService
├── _kbMouseSet (ICN_KeyboardMouse.asset)
├── _xboxSet (ICN_Xbox.asset)
├── _playStationSet (ICN_PlayStation.asset)
└── _switchSet (ICN_Switch.asset)
│ GetActionIcon(actionName)
│ → GetActionEffectivePath() ← 读取 InputActionAsset + 改键覆盖路径
│ → InputDeviceIconSetSO.GetIcon(path)
UI 消费者:
├── InteractPromptWidget → Image (按键图标) + TMP_Text (动作名称)
└── InputIconImage → Image (任意场景内按键提示)
```
### 核心设计要点
| 要点 | 说明 |
|------|------|
| **改键自动更新** | `InputIconService.GetActionEffectivePath()` 使用 `binding.effectivePath`该值已内置玩家改键后的覆盖路径UI 无需额外处理 |
| **多设备无缝切换** | 玩家随时插拔手柄,`InputDeviceDetector` 实时检测最后一次输入来自哪个设备,图标自动切换,无卡顿 |
| **回退机制** | 若某设备图标集未配置null`InputIconService` 自动回退到键鼠集 |
| **不走 Addressables** | 4 套图标集 SO 通过 `SerializeField` 直接挂载在 Persistent 场景的 `InputIconService` 组件上,随常驻场景加载,零加载延迟 |
---
## 2. InputDeviceIconSetSO 字段详解
> 路径:`Assets/_Game/Data/UI/InputIcons/ICN_{设备}.asset`
> 菜单:`Create > BaseGames/UI/Input Device Icon Set`
### 2.1 顶层字段
#### `_deviceType`InputDeviceType 枚举)
| 枚举值 | 对应设备 | 说明 |
|--------|---------|------|
| `KeyboardMouse` | 键盘 + 鼠标 | 默认设备,未识别到手柄时回退到此 |
| `XboxController` | Xbox 手柄 / 其他 XInput 手柄 | 包含 Xbox One、Xbox Series、大多数第三方 PC 手柄 |
| `PlayStationController` | DualShock 4 / DualSensePS5| 通过 Sony 布局层次或 manufacturer 字段识别 |
| `SwitchController` | Switch Pro Controller / Joy-Con | 通过 Nintendo 厂商字段或布局识别 |
**用途**
- 编辑器工具Input Icon Studio用此字段将 SO 自动归类到对应设备标签栏
- `AddressableRules` 工具可依此字段批量校验命名一致性
- **不影响运行时逻辑**:运行时由 `InputIconService` 按字段顺序_kbMouseSet / _xboxSet / _playStationSet / _switchSet直接选择不读取此字段
> ⚠️ 务必与文件命名对应:`ICN_KeyboardMouse.asset` → `KeyboardMouse`
---
#### `_entries`IconEntry 数组)
每个元素代表「一个按键路径 → 一张 Sprite」的映射关系。
| 字段 | 类型 | 说明 |
|------|------|------|
| `BindingPath` | string | InputSystem 绑定路径(见 §3 参考表)。**大小写不敏感**,匹配时使用 `OrdinalIgnoreCase` |
| `Icon` | Sprite | 该按键对应的图标 Sprite规格要求见 §4|
**注意事项**
1. **路径必须是 InputSystem 的标准路径格式**,不是自定义字符串。路径来源:
- 运行时从 `binding.effectivePath` 获取(含改键)
- 编辑时从 `InputActionAsset` 的 Binding 列表获取
- 可在 Input Icon Studio 的 Action 列表中查看当前绑定路径预览
2. **改键后路径会变化**:例如玩家将「跳跃」从 `<Keyboard>/space` 改为 `<Keyboard>/leftShift`,系统会查询 `<Keyboard>/leftShift`。因此图标集中需要覆盖所有玩家可能绑定的物理按键,而非仅默认按键。
3. **同一按键路径在多个 Action 中可复用**:图标集是路径 → Sprite 的扁平映射,一张 Sprite 可对应多个 Action 的查询结果(只要路径相同)。
4. **路径不存在时行为**`GetIcon()` 返回 `null`UI 消费方(`InteractPromptWidget` / `InputIconImage`)会将 `Image.enabled = false`,隐藏图标区域,不显示错误占位图。
---
### 2.2 Inspector 快捷操作InputDeviceIconSetSOEditor
在 Inspector 选中 `ICN_*.asset` 时,自定义编辑器提供以下操作:
| 按钮 | 功能 |
|------|------|
| **从 Action Asset 填充路径** | 读取 `InputReaderSO` 中的 `InputActionAsset`,自动提取当前设备方案的所有绑定路径,批量创建 `_entries` 条目Icon 留空,待手动填入)|
| **在 Input Icon Studio 打开** | 跳转到 Input Icon Studio 窗口并定位此 SO |
| **复制路径** | 右键单个条目的路径字段,复制路径字符串到剪贴板 |
---
## 3. IconEntry 绑定路径参考表
### 3.1 键盘常用路径(`ICN_KeyboardMouse.asset`
| 描述 | BindingPath |
|------|-------------|
| 空格键 | `<Keyboard>/space` |
| E 键(交互默认) | `<Keyboard>/e` |
| F 键 | `<Keyboard>/f` |
| R 键 | `<Keyboard>/r` |
| Shift 键(左) | `<Keyboard>/leftShift` |
| Ctrl 键(左) | `<Keyboard>/leftCtrl` |
| Tab 键 | `<Keyboard>/tab` |
| Escape 键 | `<Keyboard>/escape` |
| Enter 键 | `<Keyboard>/enter` |
| 方向键上 | `<Keyboard>/upArrow` |
| 方向键下 | `<Keyboard>/downArrow` |
| 方向键左 | `<Keyboard>/leftArrow` |
| 方向键右 | `<Keyboard>/rightArrow` |
| W / A / S / D | `<Keyboard>/w` · `<Keyboard>/a` · `<Keyboard>/s` · `<Keyboard>/d` |
| 数字键 1~4 | `<Keyboard>/1` ~ `<Keyboard>/4` |
| 鼠标左键 | `<Mouse>/leftButton` |
| 鼠标右键 | `<Mouse>/rightButton` |
| 鼠标中键 | `<Mouse>/middleButton` |
| 滚轮向上 | `<Mouse>/scroll/up` |
| 滚轮向下 | `<Mouse>/scroll/down` |
### 3.2 Xbox / XInput 手柄路径(`ICN_Xbox.asset`
| 描述 | BindingPath |
|------|-------------|
| A 键(南方键,交互默认) | `<Gamepad>/buttonSouth` |
| B 键(东方键) | `<Gamepad>/buttonEast` |
| X 键(西方键) | `<Gamepad>/buttonWest` |
| Y 键(北方键) | `<Gamepad>/buttonNorth` |
| 左摇杆按下 | `<Gamepad>/leftStickButton` |
| 右摇杆按下 | `<Gamepad>/rightStickButton` |
| 左肩键 LB | `<Gamepad>/leftShoulder` |
| 右肩键 RB | `<Gamepad>/rightShoulder` |
| 左扳机 LT | `<Gamepad>/leftTrigger` |
| 右扳机 RT | `<Gamepad>/rightTrigger` |
| 十字键上 | `<Gamepad>/dpad/up` |
| 十字键下 | `<Gamepad>/dpad/down` |
| 十字键左 | `<Gamepad>/dpad/left` |
| 十字键右 | `<Gamepad>/dpad/right` |
| 开始键 Start / Menu | `<Gamepad>/start` |
| 选择键 Select / View | `<Gamepad>/select` |
| 左摇杆方向 | `<Gamepad>/leftStick` |
| 右摇杆方向 | `<Gamepad>/rightStick` |
### 3.3 PlayStation 手柄路径(`ICN_PlayStation.asset`
> **路径与 Xbox 完全相同**(均使用通用 `<Gamepad>` 路径),只有图标 Sprite 不同。
> PlayStation 的按键物理布局与 Xbox 一致(南/东/西/北方键),区别在于图标(✕ / ○ / □ / △ vs A/B/X/Y
| 描述 | PS 图标 | BindingPath与 Xbox 相同) |
|------|---------|--------------------------|
| 南方键 Cross | ✕ 图标 | `<Gamepad>/buttonSouth` |
| 东方键 Circle | ○ 图标 | `<Gamepad>/buttonEast` |
| 西方键 Square | □ 图标 | `<Gamepad>/buttonWest` |
| 北方键 Triangle | △ 图标 | `<Gamepad>/buttonNorth` |
| L1 | L1 图标 | `<Gamepad>/leftShoulder` |
| R1 | R1 图标 | `<Gamepad>/rightShoulder` |
| L2 | L2 图标 | `<Gamepad>/leftTrigger` |
| R2 | R2 图标 | `<Gamepad>/rightTrigger` |
| L3左摇杆按下| L3 图标 | `<Gamepad>/leftStickButton` |
| R3右摇杆按下| R3 图标 | `<Gamepad>/rightStickButton` |
| Options 键 | Options 图标 | `<Gamepad>/start` |
| TouchPad / Share | TouchPad 图标 | `<Gamepad>/select` |
### 3.4 Nintendo Switch 手柄路径(`ICN_Switch.asset`
> **路径同样与 Xbox/PS 共用 `<Gamepad>` 路径**,仅图标不同。
> Switch 的南方键是 B东方键是 A与 Xbox 方向相反,务必使用正确的 Switch 图标。
| 描述 | Switch 图标 | BindingPath |
|------|------------|-------------|
| 南方键 B | B 图标 | `<Gamepad>/buttonSouth` |
| 东方键 A | A 图标 | `<Gamepad>/buttonEast` |
| 西方键 Y | Y 图标 | `<Gamepad>/buttonWest` |
| 北方键 X | X 图标 | `<Gamepad>/buttonNorth` |
| L 键 | L 图标 | `<Gamepad>/leftShoulder` |
| R 键 | R 图标 | `<Gamepad>/rightShoulder` |
| ZL 键 | ZL 图标 | `<Gamepad>/leftTrigger` |
| ZR 键 | ZR 图标 | `<Gamepad>/rightTrigger` |
| + 键 | + 图标 | `<Gamepad>/start` |
| - 键 | - 图标 | `<Gamepad>/select` |
---
## 4. 图片规格要求
### 4.1 分辨率与尺寸
| 场景 | 建议尺寸 | 说明 |
|------|---------|------|
| **HUD 交互提示**InteractPromptWidget 主图标)| **64×64 px** | 运行时显示在 32×32 的 Image 控件中2× 超采样保证清晰度 |
| **InputIconImage**(通用场景内提示)| **64×64 px** | 与上同 |
| Input Icon Studio 大图预览 | 64×64 px | 编辑器预览用,与运行时同图 |
> 最小可用尺寸 **32×32 px**,但在 Retina / 高 DPI 屏幕上会模糊。
> 不建议超过 **128×128 px**Atlas 占用增大但视觉收益有限)。
### 4.2 格式
| 项目 | 规范 |
|------|------|
| **文件格式** | PNG推荐支持透明度|
| **色彩模式** | RGBA需要透明背景|
| **背景** | **透明**,图标主体不超过 90% 画布区域,四周留 5% 安全边距 |
| **颜色风格** | 与游戏 UI 风格一致:像素风选用扁平配色;高清风可使用渐变 |
### 4.3 Unity Import Settings
在 Inspector 选中图标 PNG 后,设置以下 Import Settings
| 设置项 | 推荐值 | 原因 |
|-------|-------|------|
| **Texture Type** | `Sprite (2D and UI)` | 用于 UI Image 组件 |
| **Sprite Mode** | `Single`(单图)或 `Multiple`Sprite Sheet | 单张图标用 Single若多个图标合并到一张 Atlas 用 Multiple |
| **Pixels Per Unit** | `32`(像素风项目)或 `64`(高清项目,与 Canvas 缩放匹配) | 与项目全局 PPU 保持一致 |
| **Filter Mode** | `Point (no filter)`(像素风)或 `Bilinear`(高清风) | 像素风必须用 Point否则会模糊 |
| **Compression** | `None`(开发阶段)/ `ASTC 4x4`(移动端发布)| 图标资产小,压缩感知不明显,开发期优先无损 |
| **Generate Mip Maps** | **关闭** | 2D UI 不需要 Mip Maps |
| **Read/Write Enabled** | **关闭**(除非代码读像素)| 减少内存占用 |
| **Max Size** | `128` | 图标不需要超过 128px |
| **Alpha Is Transparency** | **开启** | 正确处理透明边缘抗锯齿 |
### 4.4 Sprite Atlas 策略
将同一设备的所有按键图标合并到一张 Atlas可减少 DrawCall
| Atlas 文件 | 覆盖内容 | 存放位置 |
|-----------|---------|---------|
| `Atlas_UI_Keys_KBM.spriteatlas` | 键鼠全部按键图标 | `_Game/Art/UI/Icons/InputKeys/` |
| `Atlas_UI_Keys_Xbox.spriteatlas` | Xbox 全部按键图标 | `_Game/Art/UI/Icons/InputKeys/` |
| `Atlas_UI_Keys_PS.spriteatlas` | PlayStation 全部按键图标 | `_Game/Art/UI/Icons/InputKeys/` |
| `Atlas_UI_Keys_Switch.spriteatlas` | Switch 全部按键图标 | `_Game/Art/UI/Icons/InputKeys/` |
> **Atlas 本身不注册 Addressable**,由引用它的 SO 间接带入包体。
### 4.5 命名规范
```
IC_Key_{DeviceShort}_{KeyName}.png
DeviceShort
KBM → 键盘鼠标
Xbox → Xbox / XInput
PS → PlayStation (DualShock/DualSense)
Switch → Nintendo Switch
示例:
IC_Key_KBM_Space.png ← <Keyboard>/space
IC_Key_KBM_E.png ← <Keyboard>/e
IC_Key_KBM_LMB.png ← <Mouse>/leftButton
IC_Key_Xbox_South.png ← <Gamepad>/buttonSouth (A键)
IC_Key_Xbox_RT.png ← <Gamepad>/rightTrigger
IC_Key_PS_Cross.png ← <Gamepad>/buttonSouth (✕键)
IC_Key_PS_R2.png ← <Gamepad>/rightTrigger
IC_Key_Switch_South.png ← <Gamepad>/buttonSouth (B键)
IC_Key_Switch_ZR.png ← <Gamepad>/rightTrigger
```
---
## 5. InputIconService 配置字段
> 组件挂载位置:`Persistent 场景` → `UIRoot` → `InputIconService`
| 字段Header | 字段名 | 类型 | 说明 |
|--------------|--------|------|------|
| **Input** | `_inputReader` | InputReaderSO | 游戏全局 InputReaderSO用于查询 Action 的绑定路径(含改键覆盖)|
| **Icon Sets** | `_kbMouseSet` | InputDeviceIconSetSO | 键鼠图标集(`ICN_KeyboardMouse.asset`)。**默认激活集,任何未识别设备均回退到此** |
| **Icon Sets** | `_xboxSet` | InputDeviceIconSetSO | Xbox 图标集(`ICN_Xbox.asset`)。可为 nullnull 时回退到 `_kbMouseSet` |
| **Icon Sets** | `_playStationSet` | InputDeviceIconSetSO | PlayStation 图标集(`ICN_PlayStation.asset`)。可为 nullnull 时回退到 `_kbMouseSet` |
| **Icon Sets** | `_switchSet` | InputDeviceIconSetSO | Switch 图标集(`ICN_Switch.asset`)。可为 nullnull 时回退到 `_kbMouseSet` |
| **Event Channels** | `_onDeviceChanged` | InputDeviceTypeEventChannelSO | 订阅 InputDeviceDetector 广播的设备切换事件(`EVT_InputDeviceChanged.asset`|
### 5.1 回退规则
```
设备切换到 XboxController
_xboxSet != null → 使用 _xboxSet
_xboxSet == null → 回退 _kbMouseSet
设备切换到 PlayStationController
_playStationSet != null → 使用 _playStationSet
_playStationSet == null → 回退 _kbMouseSet
设备切换到 SwitchController
_switchSet != null → 使用 _switchSet
_switchSet == null → 回退 _kbMouseSet
```
> 回退到 `_kbMouseSet` 时,手柄路径(如 `<Gamepad>/buttonSouth`)查询 `_kbMouseSet` 会返回 null因为键鼠集里没有 Gamepad 路径),最终 `Image.enabled = false`,图标区域隐藏。
> **建议**:四套图标集均配置完整,避免出现图标消失的情况。
---
## 6. 设备自动识别逻辑
> 组件:`InputDeviceDetector`,挂在 `UIRoot` 上,与 `InputIconService` 同节点
### 6.1 识别优先级
```
有输入事件到来时StateEvent / DeltaStateEvent
1. device is Keyboard or Mouse → KeyboardMouse
2. device is Gamepad
2a. 布局继承自 DualShockGamepad
或 product 含 "DualShock"/"DualSense"
或 manufacturer 含 "Sony" → PlayStationController
2b. 布局继承自 SwitchProControllerHID
或 product 含 "Switch"/"Joy-Con"
或 manufacturer 含 "Nintendo" → SwitchController
2c. 布局继承自 XInputController
或 product 含 "Xbox"
或 interfaceName == "XInput" → XboxController
2d. 未匹配(第三方 HID 手柄) → XboxController默认
3. 其他设备类型(触摸屏等) → KeyboardMouse默认
```
### 6.2 热插拔行为
- 手柄**拔出**`InputDeviceChange.Removed` 不触发设备切换,保持当前图标集,防止玩家无意拔线导致图标乱跳
- 手柄**重连**`InputDeviceChange.Reconnected/Added` 同样不自动切换,等待下一次实际输入事件再切换
- 玩家**实际操作**键鼠或手柄时,才触发切换 —— 这是最符合用户体验的设计
---
## 7. InteractPromptWidget 配置
> 组件挂载位置:`Persistent 场景` → `HUD Canvas` → `InteractPromptWidget_GO`
### 7.1 字段说明
| Header | 字段 | 类型 | 说明 |
|--------|------|------|------|
| **UI 引用** | `_keyIcon` | Image | 显示按键图标的 UI Image 组件。宽高建议 **32×32**Canvas 坐标空间Preserve Aspect = true |
| **UI 引用** | `_labelText` | TMP_Text | 显示动作说明文字(如"交互"、"拾取"),对应 `InteractPromptEvent.LabelText` |
| **UI 引用** | `_root` | GameObject | 整个提示 UI 的根节点(含图标+文字),`SetActive(false)` 时完整隐藏。若为 null则控制 Widget 所在 GameObject 本身 |
| **Event Channels** | `_onShowPrompt` | InteractPromptEventChannelSO | 订阅此频道接收显示指令,负载为 `InteractPromptEvent { ActionName, LabelText }` |
| **Event Channels** | `_onHidePrompt` | VoidEventChannelSO | 订阅此频道接收隐藏指令(玩家离开交互范围时触发)|
### 7.2 InteractPromptEvent 负载
| 字段 | 类型 | 说明 |
|------|------|------|
| `ActionName` | string | InputSystem Action 名称,如 `"Interact"`。用于在 InputIconService 查询对应图标 |
| `LabelText` | string | 显示的文字,如 `"交互"` / `"拾取"` / `"打开"`。由交互物件在广播时指定(来自 `Interactable.InteractPrompt`|
### 7.3 延迟绑定机制
`InteractPromptWidget``OnEnable` 时**不**立即获取 `IInputIconService`,而是在首次 `ShowPrompt()` 时才获取。这避免了执行顺序问题(`InputIconService.Awake()` 可能晚于 Widget 的 `OnEnable`)。
---
## 8. InputIconImage 配置
> 适用场景:教程界面、暂停菜单、任何需要显示单个按键图标的 UI Image
> 组件:`InputIconImage`,挂在带有 `Image` 组件的 UI GameObject 上
### 8.1 字段说明
| 字段 | 类型 | 说明 |
|------|------|------|
| `_mode` | LookupMode 枚举 | 查询模式(见下表)|
| `_actionName` | string | **ByActionName 模式**:填写 Action 名称(如 `"Jump"`),自动跟随改键和设备切换 |
| `_bindingPath` | string | **ByBindingPath 模式**:填写固定路径(如 `<Keyboard>/space`),不跟随改键 |
### 8.2 LookupMode 枚举
| 枚举值 | 适用场景 | 改键响应 | 设备切换响应 |
|--------|---------|---------|------------|
| `ByActionName`(推荐)| 所有正常游戏内 UI 提示 | ✅ 自动更新 | ✅ 自动切换 |
| `ByBindingPath` | 教程截图说明、固定按键展示(如「按 Space 确认」这类静态文案)| ❌ 不更新 | ❌ 不切换 |
> `ByBindingPath` 模式目前图标查询功能为低优先级,实际返回 null`Image.enabled = false`)。如需支持,可在 `InputIconService` 中通过 `GetOrDefault<IInputIconService>()` + `GetActiveSet().GetIcon(path)` 手动实现。
---
## 9. 完整工作流:从零到运行
### Step 1 · 导入图标素材
```
1. 在 _Game/Art/UI/Icons/InputKeys/ 下创建4个子文件夹KBM / Xbox / PS / Switch
2. 将各设备按键图标 PNG 放入对应子文件夹
3. 在 Unity 中选中所有图标,统一设置 Import Settings
- Texture Type: Sprite (2D and UI)
- Sprite Mode: Single
- Pixels Per Unit: 32像素风
- Filter Mode: Point (no filter)
- Max Size: 128
- Generate Mip Maps: 关闭
- Alpha Is Transparency: 开启
4. 创建4个 Sprite Atlas见 §4.4),将图标拖入对应 Atlas
```
### Step 2 · 创建图标集 SO
方式 A推荐— 使用 Input Icon Studio
```
1. 菜单 BaseGames/Input Icon Studio
2. 在「图标集资产状态」区域,点击对应设备的「+ 新建」
3. 保存路径_Game/Data/UI/InputIcons/ICN_{设备}.asset
4. 确认 _deviceType 字段与文件名一致
```
方式 B — 手动创建:
```
1. Project 窗口右键 → Create → BaseGames/UI/Input Device Icon Set
2. 重命名为 ICN_{设备}.asset移至 _Game/Data/UI/InputIcons/
3. 在 Inspector 中设置 _deviceType
```
### Step 3 · 填充绑定路径与图标
```
在 Input Icon Studio 中:
1. 顶部选择 InputReaderSO自动加载唯一实例
2. 点击设备标签(键鼠 / Xbox / PS / Switch
3. 左列选择 Action如 Interact
4. 右列拖入对应 Sprite
5. 覆盖率圆点变绿(●)= 已配置
或在 Inspector 中直接编辑 _entries 数组:
1. 展开 _entries
2. 点击「从 Action Asset 填充路径」批量生成所有条目Icon 留空)
3. 逐条拖入 Sprite
```
### Step 4 · 绑定到 InputIconService
```
1. 打开 Persistent 场景
2. 选中 UIRoot GameObject或单独的 InputIconService GameObject
3. 找到 InputIconService 组件
4. 拖入对应字段:
_inputReader → InputReaderSO通常为 PLY_InputReader.asset
_kbMouseSet → ICN_KeyboardMouse.asset
_xboxSet → ICN_Xbox.asset
_playStationSet → ICN_PlayStation.asset
_switchSet → ICN_Switch.asset
_onDeviceChanged → EVT_InputDeviceChanged.asset
```
### Step 5 · 配置 InteractPromptWidget
```
1. 在 HUD Canvas 下找到或创建InteractPromptWidget GameObject
2. 挂上 InteractPromptWidget 组件
3. 配置字段:
_keyIcon → 按键图标 Image 组件32×32 UI Image
_labelText → 动作说明 TMP_Text 组件
_root → 整个提示 UI 根节点(控制显隐)
_onShowPrompt → EVT_ShowInteractPrompt.asset
_onHidePrompt → EVT_HideInteractPrompt.asset
```
### Step 6 · 验证
```
进入 PlayMode
✅ 走近 NPC / 宝箱 → 右下角显示按键图标 + "交互"文字
✅ 插入 Xbox 手柄 → 图标自动切换为 Xbox A 键图标
✅ 插入 DualShock → 图标切换为 PS ✕ 图标
✅ 改键(在游戏内按键重映射菜单)→ 图标同步更新
✅ 拔出手柄 → 恢复键鼠图标(下次键盘/鼠标操作后切换)
```
---
## 10. 常见问题与排查
### 图标不显示Image 被隐藏)
1. 检查 `InputIconService` 的4个图标集字段是否已赋值
2. 检查 `_entries` 中是否有该 Action 对应设备的绑定路径条目
3. 在 Input Icon Studio 左列查看该 Action 的绑定路径预览,与 `_entries` 中的 BindingPath 对比
4. 检查 `InputReaderSO` 是否正确赋值给 `InputIconService._inputReader`
5. 运行时打开 Event Bus Monitor`BaseGames/Events/Event Bus Monitor`)确认 `EVT_InputDeviceChanged` 是否正常广播
### 改键后图标不更新
1. 确认 `InputIconService.OnEnable()` 已订阅 `InputSystem.onActionChange`Inspector 中确保组件处于 active 状态)
2. 确认 `_entries` 中包含改键后目标按键的路径(如玩家改到 `<Keyboard>/leftShift`,需有对应条目)
3. 改键后图标集必须覆盖所有可能被绑定的物理按键;对于玩家自定义改键的游戏,建议将整个键盘的常用键全部配置进 `_kbMouseSet._entries`
### 设备切换后图标不更新
1. 确认 `InputDeviceDetector` 组件已挂载在场景中UIRoot 上)
2. 确认 `InputDeviceDetector._onDeviceChanged``InputIconService._onDeviceChanged` 引用的是**同一个** `EVT_InputDeviceChanged.asset`
3. 插入手柄后需要实际按下任意按键才会触发识别(纯插入不切换)
### PlayStation 手柄显示 Xbox 图标
1. 在 Unity 菜单 `Window > Analysis > Input Debugger` 查看手柄的 `layout` 字段
2. 若 layout 不继承自 `DualShockGamepad`(部分 USB 适配器会出现此情况),系统会回退到 XboxController
3. 可在 `InputDeviceDetector.ClassifyDevice()` 中添加额外的 product 字符串匹配规则
### 编辑器工具显示覆盖率为 0/0
- 说明 `InputReaderSO` 未在 Input Icon Studio 工具栏加载,或 `InputActionAsset` 的 Gameplay ActionMap 为空
- 在工具栏 ObjectField 手动指定 InputReaderSO然后点击「⟳ 刷新」
---
## 附录:相关文件速查
| 用途 | 文件路径 |
|------|---------|
| 图标集 SO键鼠| `_Game/Data/UI/InputIcons/ICN_KeyboardMouse.asset` |
| 图标集 SOXbox| `_Game/Data/UI/InputIcons/ICN_Xbox.asset` |
| 图标集 SOPS| `_Game/Data/UI/InputIcons/ICN_PlayStation.asset` |
| 图标集 SOSwitch| `_Game/Data/UI/InputIcons/ICN_Switch.asset` |
| 按键图标图片 | `_Game/Art/UI/Icons/InputKeys/` |
| 核心 SO 脚本 | `Scripts/UI/InputDeviceIconSetSO.cs` |
| 服务接口 | `Scripts/UI/IInputIconService.cs` |
| 服务实现 | `Scripts/UI/InputIconService.cs` |
| 设备检测器 | `Scripts/UI/InputDeviceDetector.cs` |
| 交互提示 Widget | `Scripts/UI/HUD/InteractPromptWidget.cs` |
| 通用图标 Image | `Scripts/UI/InputDeviceIconSwitcher.cs`(含 InputIconImage 类)|
| 编辑器窗口 | `Scripts/Editor/Input/InputIconStudioWindow.cs` |
| 编辑器 Inspector | `Scripts/Editor/Input/InputDeviceIconSetSOEditor.cs` |
| 事件频道 | `Data/Events/UI/EVT_InputDeviceChanged.asset` |
| 交互提示事件 | `Data/Events/World/EVT_ShowInteractPrompt.asset` |

View File

@@ -11,6 +11,8 @@
| 文件 | 覆盖系统 | | 文件 | 覆盖系统 |
|---|---| |---|---|
| [PlayerJumpDash_Tuning.md](PlayerJumpDash_Tuning.md) | 玩家跳跃 / 冲刺手感参数 | | [PlayerJumpDash_Tuning.md](PlayerJumpDash_Tuning.md) | 玩家跳跃 / 冲刺手感参数 |
| [WeaponSO_Tuning.md](WeaponSO_Tuning.md) | 武器连击 / 伤害 / HitBox 参数 |
| [InputDeviceIconSet_Tuning.md](InputDeviceIconSet_Tuning.md) | 按键图标集 SO 配置、BindingPath 参考表、图片规格、完整工作流 |
--- ---