diff --git a/Assets/_Game/Art/UI/Icons/InputKeys.meta b/Assets/_Game/Art/UI/Icons/InputKeys.meta
new file mode 100644
index 0000000..1357af7
--- /dev/null
+++ b/Assets/_Game/Art/UI/Icons/InputKeys.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 4c70a04eb99184247a53f1631e082c50
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Game/Data/Player/PLY_PlayerMovementConfig.asset b/Assets/_Game/Data/Player/PLY_PlayerMovementConfig.asset
index 63bc628..2dd3cde 100644
--- a/Assets/_Game/Data/Player/PLY_PlayerMovementConfig.asset
+++ b/Assets/_Game/Data/Player/PLY_PlayerMovementConfig.asset
@@ -14,15 +14,15 @@ MonoBehaviour:
m_EditorClassIdentifier:
RunSpeed: 6
AirDragFactor: 1
- JumpForce: 20
+ JumpForce: 17.5
CoyoteTime: 0.12
- FallGravityMult: 2.5
- MaxFallSpeed: 28
+ FallGravityMult: 2
+ MaxFallSpeed: 15
JumpCutMultiplier: 0.321
ApexThreshold: 3
ApexGravityMultiplier: 0.3
MaxAirJumps: 5
- DoubleJumpForce: 15
+ DoubleJumpForce: 14
DashSpeed: 20
DashDuration: 0.25
DashCooldown: 0.4
@@ -37,8 +37,8 @@ MonoBehaviour:
WallGrabHeightTolerance: 0.05
WallCoyoteTime: 0.12
WallJumpAwayForceX: 10
- WallJumpAwayForceY: 18
+ WallJumpAwayForceY: 14
WallJumpTowardForceX: -6
- WallJumpTowardForceY: 18
+ WallJumpTowardForceY: 14
WallJumpInputLockDuration: 0.15
- DefaultGravityScale: 6
+ DefaultGravityScale: 5
diff --git a/Assets/_Game/Data/UI/Icons.meta b/Assets/_Game/Data/UI/Icons.meta
new file mode 100644
index 0000000..9603d9f
--- /dev/null
+++ b/Assets/_Game/Data/UI/Icons.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 9006d69429771544da69a6fb803ee6cf
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Game/Data/UI/InputIcons.meta b/Assets/_Game/Data/UI/InputIcons.meta
new file mode 100644
index 0000000..c3f9859
--- /dev/null
+++ b/Assets/_Game/Data/UI/InputIcons.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 6c4657c9f87cec046aef30d9c2e83bc7
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Game/Data/UI/InputIcons/ICN_Keyboard.asset b/Assets/_Game/Data/UI/InputIcons/ICN_Keyboard.asset
new file mode 100644
index 0000000..e4dcb16
--- /dev/null
+++ b/Assets/_Game/Data/UI/InputIcons/ICN_Keyboard.asset
@@ -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: []
diff --git a/Assets/_Game/Data/UI/InputIcons/ICN_Keyboard.asset.meta b/Assets/_Game/Data/UI/InputIcons/ICN_Keyboard.asset.meta
new file mode 100644
index 0000000..8169336
--- /dev/null
+++ b/Assets/_Game/Data/UI/InputIcons/ICN_Keyboard.asset.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 7faaada188bdae2499f9607b5c13b11b
+NativeFormatImporter:
+ externalObjects: {}
+ mainObjectFileID: 11400000
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Game/Data/UI/InputIcons/ICN_PlayStation.asset b/Assets/_Game/Data/UI/InputIcons/ICN_PlayStation.asset
new file mode 100644
index 0000000..bae42cf
--- /dev/null
+++ b/Assets/_Game/Data/UI/InputIcons/ICN_PlayStation.asset
@@ -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: []
diff --git a/Assets/_Game/Data/UI/InputIcons/ICN_PlayStation.asset.meta b/Assets/_Game/Data/UI/InputIcons/ICN_PlayStation.asset.meta
new file mode 100644
index 0000000..1005891
--- /dev/null
+++ b/Assets/_Game/Data/UI/InputIcons/ICN_PlayStation.asset.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 441c8b987e18c07409de8d6ba9b871cc
+NativeFormatImporter:
+ externalObjects: {}
+ mainObjectFileID: 11400000
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Game/Data/UI/InputIcons/ICN_Switch.asset b/Assets/_Game/Data/UI/InputIcons/ICN_Switch.asset
new file mode 100644
index 0000000..06e2e45
--- /dev/null
+++ b/Assets/_Game/Data/UI/InputIcons/ICN_Switch.asset
@@ -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: []
diff --git a/Assets/_Game/Data/UI/InputIcons/ICN_Switch.asset.meta b/Assets/_Game/Data/UI/InputIcons/ICN_Switch.asset.meta
new file mode 100644
index 0000000..155fa7f
--- /dev/null
+++ b/Assets/_Game/Data/UI/InputIcons/ICN_Switch.asset.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 87d27ef72ec852548a127d7acb71d1a3
+NativeFormatImporter:
+ externalObjects: {}
+ mainObjectFileID: 11400000
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Game/Data/UI/InputIcons/ICN_Xbox.asset b/Assets/_Game/Data/UI/InputIcons/ICN_Xbox.asset
new file mode 100644
index 0000000..6d40fb9
--- /dev/null
+++ b/Assets/_Game/Data/UI/InputIcons/ICN_Xbox.asset
@@ -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: []
diff --git a/Assets/_Game/Data/UI/InputIcons/ICN_Xbox.asset.meta b/Assets/_Game/Data/UI/InputIcons/ICN_Xbox.asset.meta
new file mode 100644
index 0000000..3134729
--- /dev/null
+++ b/Assets/_Game/Data/UI/InputIcons/ICN_Xbox.asset.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 8acf7a7648c79274cb31cfe2285f7746
+NativeFormatImporter:
+ externalObjects: {}
+ mainObjectFileID: 11400000
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Game/Scenes/Testings/FengXianMountain.unity b/Assets/_Game/Scenes/Testings/FengXianMountain.unity
index 9dabd55..5622f57 100644
--- a/Assets/_Game/Scenes/Testings/FengXianMountain.unity
+++ b/Assets/_Game/Scenes/Testings/FengXianMountain.unity
@@ -31682,6 +31682,14 @@ PrefabInstance:
serializedVersion: 3
m_TransformParent: {fileID: 783576435}
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}
propertyPath: m_Name
value: Player
diff --git a/Assets/_Game/Scripts/Core/Events/InteractPromptEvent.cs b/Assets/_Game/Scripts/Core/Events/InteractPromptEvent.cs
new file mode 100644
index 0000000..81ec83a
--- /dev/null
+++ b/Assets/_Game/Scripts/Core/Events/InteractPromptEvent.cs
@@ -0,0 +1,22 @@
+namespace BaseGames.Core.Events
+{
+ ///
+ /// 交互提示事件负载。
+ /// 由 InteractableDetector 广播,包含触发动作名称和显示文本,
+ /// UI 层(InteractPromptWidget)据此查询图标并显示提示。
+ ///
+ public readonly struct InteractPromptEvent
+ {
+ /// InputSystem Action 名称,如 "Interact"。用于查询按键图标。
+ public readonly string ActionName;
+
+ /// 交互物提供的说明文本,如 "对话"、"存档"、"传送"。
+ public readonly string LabelText;
+
+ public InteractPromptEvent(string actionName, string labelText)
+ {
+ ActionName = actionName;
+ LabelText = labelText;
+ }
+ }
+}
diff --git a/Assets/_Game/Scripts/Core/Events/InteractPromptEvent.cs.meta b/Assets/_Game/Scripts/Core/Events/InteractPromptEvent.cs.meta
new file mode 100644
index 0000000..a5c1a88
--- /dev/null
+++ b/Assets/_Game/Scripts/Core/Events/InteractPromptEvent.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 9eccce8fdbd936b46a467d078957a387
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Game/Scripts/Core/Events/InteractPromptEventChannelSO.cs b/Assets/_Game/Scripts/Core/Events/InteractPromptEventChannelSO.cs
new file mode 100644
index 0000000..c81bdc4
--- /dev/null
+++ b/Assets/_Game/Scripts/Core/Events/InteractPromptEventChannelSO.cs
@@ -0,0 +1,7 @@
+using UnityEngine;
+
+namespace BaseGames.Core.Events
+{
+ [CreateAssetMenu(menuName = "BaseGames/Events/InteractPrompt")]
+ public class InteractPromptEventChannelSO : BaseEventChannelSO { }
+}
diff --git a/Assets/_Game/Scripts/Core/Events/InteractPromptEventChannelSO.cs.meta b/Assets/_Game/Scripts/Core/Events/InteractPromptEventChannelSO.cs.meta
new file mode 100644
index 0000000..b291f2d
--- /dev/null
+++ b/Assets/_Game/Scripts/Core/Events/InteractPromptEventChannelSO.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 5e6db212f7619344588f054af0c6330a
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Game/Scripts/Editor/BaseGames.Editor.asmdef b/Assets/_Game/Scripts/Editor/BaseGames.Editor.asmdef
index c1179c6..b526c35 100644
--- a/Assets/_Game/Scripts/Editor/BaseGames.Editor.asmdef
+++ b/Assets/_Game/Scripts/Editor/BaseGames.Editor.asmdef
@@ -31,7 +31,8 @@
"BaseGames.Skills",
"BaseGames.World.Map",
"BaseGames.EventChain",
- "BaseGames.VFX"
+ "BaseGames.VFX",
+ "Unity.InputSystem"
],
"includePlatforms": [
"Editor"
diff --git a/Assets/_Game/Scripts/Editor/Events/CreateEventChannelAssets.cs b/Assets/_Game/Scripts/Editor/Events/CreateEventChannelAssets.cs
index e4b9c6e..0b14ada 100644
--- a/Assets/_Game/Scripts/Editor/Events/CreateEventChannelAssets.cs
+++ b/Assets/_Game/Scripts/Editor/Events/CreateEventChannelAssets.cs
@@ -37,6 +37,7 @@ namespace BaseGames.Editor
CreateAsset ("Core", "EVT_SceneLoaded");
CreateAsset ("Core", "EVT_FadeInRequest");
CreateAsset ("Core", "EVT_FadeOutRequest");
+ CreateAsset ("Core", "EVT_SceneWorldStateRestored"); // 场景加载完毕、世界状态恢复后触发;场景物体在此订阅并应用存档状态,淡入前保证画面正确
// ── 难度 ──────────────────────────────────────────────────────────
CreateAsset("Difficulty", "EVT_DifficultyChanged");
@@ -99,14 +100,18 @@ namespace BaseGames.Editor
CreateAsset ("World", "EVT_CheckpointReached");
CreateAsset ("World", "EVT_DoorOpened"); // 开门/交互机关(钥匙、机关等)触发自动存档
CreateAsset ("World", "EVT_ItemPickup"); // 道具/收集品获取(itemId)
+ CreateAsset ("World", "EVT_CollectiblePickup"); // 关键物品拾取(护符、道具等)触发存档;AutoSaveService / QuestManager / EventChainManager 监听
CreateAsset ("World", "EVT_CollectibleSaved"); // 持久化记录收集品(collectibleId)
CreateAsset ("World", "EVT_RoomEntered"); // 玩家进入新房间(roomId)
CreateAsset ("World", "EVT_RegionChanged"); // 玩家首次进入新区域(regionId)
CreateAsset ("World", "EVT_RevealRegion"); // 触发地图区域揭露(regionId)
+ CreateAsset ("World", "EVT_MapUpdated"); // 房间首次探索/标注时刷新(roomId);MapManager 发布,MapPanel 订阅
CreateAsset ("World", "EVT_ChallengeCompleted"); // 挑战房间通关(challengeId)
CreateAsset ("World", "EVT_ChallengeFailed"); // 挑战房间失败(challengeId)
CreateAsset ("World", "EVT_LiquidEntered"); // 玩家进入液体区域
CreateAsset ("World", "EVT_LiquidExited"); // 玩家离开液体区域
+ CreateAsset("World", "EVT_WorldMarkerActivated"); // 导航标记点激活(地图图标显示)
+ CreateAsset("World", "EVT_WorldMarkerDeactivated"); // 导航标记点失活(地图图标隐藏)
// ── 对话/商店 ─────────────────────────────────────────────────────
CreateAsset ("Dialogue", "EVT_ShopPurchase");
diff --git a/Assets/_Game/Scripts/Editor/Input.meta b/Assets/_Game/Scripts/Editor/Input.meta
new file mode 100644
index 0000000..1060cdf
--- /dev/null
+++ b/Assets/_Game/Scripts/Editor/Input.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 52933b4810ae6654c962a93708b64a8f
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Game/Scripts/Editor/Input/InputDeviceIconSetSOEditor.cs b/Assets/_Game/Scripts/Editor/Input/InputDeviceIconSetSOEditor.cs
new file mode 100644
index 0000000..5eddb93
--- /dev/null
+++ b/Assets/_Game/Scripts/Editor/Input/InputDeviceIconSetSOEditor.cs
@@ -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
+{
+ ///
+ /// InputDeviceIconSetSO 自定义 Inspector。
+ ///
+ /// 布局(从上到下):
+ /// ① 设备类型徽章 + 覆盖率芯片
+ /// ② 操作按钮工具栏(从 Action Asset 自动填充 / 打开 Studio)
+ /// ③ 按键图标条目表(Path | Icon Sprite | 48px 预览 | 删除按钮)
+ /// ④ + 新增条目 按钮
+ ///
+ [CustomEditor(typeof(InputDeviceIconSetSO))]
+ public class InputDeviceIconSetSOEditor : UnityEditor.Editor
+ {
+ private const string UssPath = "Assets/_Game/Scripts/Editor/UIToolkit/Editor.uss";
+
+ // 预设绑定路径快捷菜单(按设备类型)
+ private static readonly Dictionary s_CommonPaths = new()
+ {
+ [InputDeviceType.KeyboardMouse] = new[]
+ {
+ "/e", "/f", "/r", "/space",
+ "/enter", "/escape", "/shift",
+ "/ctrl", "/tab", "/q", "/g",
+ "/leftButton", "/rightButton", "/middleButton"
+ },
+ [InputDeviceType.XboxController] = new[]
+ {
+ "/buttonSouth", "/buttonNorth",
+ "/buttonEast", "/buttonWest",
+ "/leftShoulder", "/rightShoulder",
+ "/leftTrigger", "/rightTrigger",
+ "/start", "/select",
+ "/leftStickPress", "/rightStickPress"
+ },
+ [InputDeviceType.PlayStationController] = new[]
+ {
+ "/buttonSouth", "/buttonNorth",
+ "/buttonEast", "/buttonWest",
+ "/leftShoulder", "/rightShoulder",
+ "/leftTrigger", "/rightTrigger",
+ "/start", "/select"
+ },
+ [InputDeviceType.SwitchController] = new[]
+ {
+ "/buttonSouth", "/buttonNorth",
+ "/buttonEast", "/buttonWest",
+ "/leftShoulder", "/rightShoulder",
+ "/leftTrigger", "/rightTrigger",
+ "/start", "/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(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(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();
+ 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("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);
+ }
+ }
+}
diff --git a/Assets/_Game/Scripts/Editor/Input/InputDeviceIconSetSOEditor.cs.meta b/Assets/_Game/Scripts/Editor/Input/InputDeviceIconSetSOEditor.cs.meta
new file mode 100644
index 0000000..702216d
--- /dev/null
+++ b/Assets/_Game/Scripts/Editor/Input/InputDeviceIconSetSOEditor.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: f5106bbd57a6fb242b364657a739a8b6
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Game/Scripts/Editor/Input/InputIconStudioWindow.cs b/Assets/_Game/Scripts/Editor/Input/InputIconStudioWindow.cs
new file mode 100644
index 0000000..e347062
--- /dev/null
+++ b/Assets/_Game/Scripts/Editor/Input/InputIconStudioWindow.cs
@@ -0,0 +1,1095 @@
+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
+{
+ ///
+ /// 按键图标管理工作台(Input Icon Studio)。
+ ///
+ /// 布局:
+ /// 工具栏 (InputReaderSO 选取 + 刷新按钮)
+ /// 设备标签栏 (键鼠 / Xbox / PlayStation / Switch)
+ /// TwoPaneSplitView:
+ /// 左列:Action 列表(带覆盖指示点 + 当前绑定路径预览)
+ /// 右列:
+ /// ① 当前 Action × 当前设备的绑定路径 + Sprite 字段
+ /// ② 48px 大图预览
+ /// ③ 所有设备快览行(4 行)
+ /// ④ 模拟 InteractPromptWidget 外观预览
+ ///
+ /// 菜单:BaseGames / Input Icon Studio (priority=55)
+ ///
+ 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();
+ 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 _actionNames = new();
+
+ // ── UI 节点引用 ───────────────────────────────────────────────────────
+
+ private VisualElement _listContainer;
+ private VisualElement _detailPanel;
+ private VisualElement _deviceTabBar;
+
+ // ── 生命周期 ──────────────────────────────────────────────────────────
+
+ public void CreateGUI()
+ {
+ var uss = AssetDatabase.LoadAssetAtPath(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(
+ 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();
+ _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();
+ 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(
+ "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(),
+ };
+
+ /// 将完整绑定路径缩短为可读格式(去掉设备前缀)。
+ 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);
+ }
+}
diff --git a/Assets/_Game/Scripts/Editor/Input/InputIconStudioWindow.cs.meta b/Assets/_Game/Scripts/Editor/Input/InputIconStudioWindow.cs.meta
new file mode 100644
index 0000000..f2e15fb
--- /dev/null
+++ b/Assets/_Game/Scripts/Editor/Input/InputIconStudioWindow.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: f6534cbe9abbd954bb1ece4cbb2d7747
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Game/Scripts/Editor/UIToolkit/Editor.uss b/Assets/_Game/Scripts/Editor/UIToolkit/Editor.uss
index 56a1ab4..25763f5 100644
--- a/Assets/_Game/Scripts/Editor/UIToolkit/Editor.uss
+++ b/Assets/_Game/Scripts/Editor/UIToolkit/Editor.uss
@@ -259,3 +259,113 @@
background-color: rgba(200, 80, 80, 0.20);
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);
+}
diff --git a/Assets/_Game/Scripts/UI/HUD/HUDController.cs b/Assets/_Game/Scripts/UI/HUD/HUDController.cs
index f62b155..7cdea0f 100644
--- a/Assets/_Game/Scripts/UI/HUD/HUDController.cs
+++ b/Assets/_Game/Scripts/UI/HUD/HUDController.cs
@@ -25,8 +25,8 @@ namespace BaseGames.UI.HUD
[SerializeField] private Image[] _formIcons;
[Header("Interact Prompt")]
- [SerializeField] private TMP_Text _interactText;
- [SerializeField] private GameObject _interactPromptRoot;
+ [Tooltip("独立 Widget 组件负责渲染图标+文本,HUDController 仅保留引用供编辑器配置检查")]
+ [SerializeField] private InteractPromptWidget _interactPromptWidget;
[Header("Event Channels - Subscribe")]
[SerializeField] private IntEventChannelSO _onHPChanged;
@@ -36,8 +36,6 @@ namespace BaseGames.UI.HUD
[SerializeField] private IntEventChannelSO _onLingZhuChanged;
[SerializeField] private IntEventChannelSO _onSpringChargesChanged;
[SerializeField] private IntEventChannelSO _onFormChanged;
- [SerializeField] private StringEventChannelSO _onShowInteractPrompt;
- [SerializeField] private VoidEventChannelSO _onHideInteractPrompt;
private readonly List _hpCells = new();
private readonly List _springIcons = new();
@@ -53,8 +51,7 @@ namespace BaseGames.UI.HUD
_onLingZhuChanged?.Subscribe(UpdateLingZhu).AddTo(_subs);
_onSpringChargesChanged?.Subscribe(RebuildSpringIcons).AddTo(_subs);
_onFormChanged?.Subscribe(UpdateFormIcon).AddTo(_subs);
- _onShowInteractPrompt?.Subscribe(ShowInteractPrompt).AddTo(_subs);
- _onHideInteractPrompt?.Subscribe(HideInteractPrompt).AddTo(_subs);
+ // 交互提示由独立的 InteractPromptWidget 组件处理,HUDController 不再直接订阅
}
private void OnDisable() => _subs.Clear();
@@ -118,16 +115,5 @@ namespace BaseGames.UI.HUD
for (int i = 0; i < _formIcons.Length; i++)
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);
- }
}
}
diff --git a/Assets/_Game/Scripts/UI/HUD/InteractPromptWidget.cs b/Assets/_Game/Scripts/UI/HUD/InteractPromptWidget.cs
new file mode 100644
index 0000000..146ceee
--- /dev/null
+++ b/Assets/_Game/Scripts/UI/HUD/InteractPromptWidget.cs
@@ -0,0 +1,119 @@
+using TMPro;
+using UnityEngine;
+using UnityEngine.UI;
+using BaseGames.Core;
+using BaseGames.Core.Events;
+
+namespace BaseGames.UI.HUD
+{
+ ///
+ /// 交互提示 Widget。
+ ///
+ /// 职责:
+ /// • 订阅 InteractPromptEventChannelSO 显示/隐藏提示
+ /// • 显示按键图标(Image)+ 动作文本(TMP_Text)
+ /// • 监听 IInputIconService.OnIconSetChanged,在设备切换或改键后自动刷新图标
+ ///
+ /// 布置方式:放在 HUD Canvas 下,引用对应的事件频道 SO 资产。
+ /// 不依赖 HUDController,可独立使用。
+ ///
+ 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();
+ 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 ──────────────────────────────────────────────────────
+
+ /// 设备切换或改键后刷新图标。由 IInputIconService.OnIconSetChanged 调用。
+ 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;
+ }
+ }
+ }
+}
diff --git a/Assets/_Game/Scripts/UI/HUD/InteractPromptWidget.cs.meta b/Assets/_Game/Scripts/UI/HUD/InteractPromptWidget.cs.meta
new file mode 100644
index 0000000..4ae7edf
--- /dev/null
+++ b/Assets/_Game/Scripts/UI/HUD/InteractPromptWidget.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 85bdb69d66e546f49b6c89941beda368
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Game/Scripts/UI/IInputIconService.cs b/Assets/_Game/Scripts/UI/IInputIconService.cs
new file mode 100644
index 0000000..10de290
--- /dev/null
+++ b/Assets/_Game/Scripts/UI/IInputIconService.cs
@@ -0,0 +1,34 @@
+using System;
+using UnityEngine;
+
+namespace BaseGames.UI
+{
+ ///
+ /// 按键图标服务接口。
+ /// 根据当前输入设备和玩家实际绑定(含改键),返回对应的按键 Sprite。
+ /// 通过 ServiceLocator 注册/查找,与 UI 层完全解耦。
+ ///
+ public interface IInputIconService
+ {
+ /// 当前活跃输入设备类型。
+ InputDeviceType CurrentDevice { get; }
+
+ ///
+ /// 查询指定 Action(如 "Interact")在当前设备上的按键图标。
+ /// 若找不到图标(资源未配置)返回 null。
+ ///
+ Sprite GetActionIcon(string actionName);
+
+ ///
+ /// 查询指定 Action 在当前设备上的有效绑定路径(含改键后的路径)。
+ /// 例如:"<Keyboard>/e"、"<Gamepad>/buttonSouth"。
+ ///
+ string GetActionEffectivePath(string actionName);
+
+ ///
+ /// 当设备切换或玩家改键后触发。
+ /// 订阅此事件的 UI 组件应在回调中刷新图标显示。
+ ///
+ event Action OnIconSetChanged;
+ }
+}
diff --git a/Assets/_Game/Scripts/UI/IInputIconService.cs.meta b/Assets/_Game/Scripts/UI/IInputIconService.cs.meta
new file mode 100644
index 0000000..f0c7d54
--- /dev/null
+++ b/Assets/_Game/Scripts/UI/IInputIconService.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: b5c091c06f569c24788467c1d4796e71
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Game/Scripts/UI/InputDeviceDetector.cs b/Assets/_Game/Scripts/UI/InputDeviceDetector.cs
new file mode 100644
index 0000000..282be9d
--- /dev/null
+++ b/Assets/_Game/Scripts/UI/InputDeviceDetector.cs
@@ -0,0 +1,109 @@
+using UnityEngine;
+using UnityEngine.InputSystem;
+using UnityEngine.InputSystem.LowLevel;
+
+namespace BaseGames.UI
+{
+ ///
+ /// 设备检测器 —— 监听 InputSystem 的事件流,识别玩家最后使用的输入设备类型,
+ /// 并通过 InputDeviceTypeEventChannelSO 广播给全局。
+ ///
+ /// 布置方式:挂在 UIRoot 或常驻 GameObject 上;只需存在一个实例。
+ ///
+ public sealed class InputDeviceDetector : MonoBehaviour
+ {
+ [Header("Event Channel")]
+ [Tooltip("广播当前设备类型变化")]
+ [SerializeField] private InputDeviceTypeEventChannelSO _onDeviceChanged;
+
+ /// 当前活跃输入设备类型,供轮询使用。
+ 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() && !eventPtr.IsA()) 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 ─────────────────────────────────────────────
+
+ ///
+ /// 根据 InputDevice 的布局层次识别设备类型。
+ /// Unity InputSystem 的设备层次:
+ /// DualShockGamepad → Gamepad → HID
+ /// XInputController → Gamepad → HID
+ /// SwitchProControllerHID → Gamepad → HID
+ /// Keyboard / Mouse
+ ///
+ 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;
+ }
+ }
+}
diff --git a/Assets/_Game/Scripts/UI/InputDeviceDetector.cs.meta b/Assets/_Game/Scripts/UI/InputDeviceDetector.cs.meta
new file mode 100644
index 0000000..b9a3852
--- /dev/null
+++ b/Assets/_Game/Scripts/UI/InputDeviceDetector.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: e2705ff30800d20449273062f56e1989
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Game/Scripts/UI/InputDeviceIconSetSO.cs b/Assets/_Game/Scripts/UI/InputDeviceIconSetSO.cs
index 0876392..5f389ab 100644
--- a/Assets/_Game/Scripts/UI/InputDeviceIconSetSO.cs
+++ b/Assets/_Game/Scripts/UI/InputDeviceIconSetSO.cs
@@ -13,18 +13,27 @@ namespace BaseGames.UI
[System.Serializable]
public struct IconEntry
{
- public string BindingPath; // InputSystem binding path,e.g. "/space"
- public Sprite Icon;
+ [Tooltip("InputSystem 绑定路径,如 /space 或 /buttonSouth。改键后路径变化,图标集中须包含全部可能按键的映射。")]
+ public string BindingPath;
+ public Sprite Icon;
}
+ [Tooltip("标识此图标集对应的输入设备类型(仅作编辑器说明,运行时由 InputIconService 选择)")]
+ [SerializeField] private InputDeviceType _deviceType;
+
[SerializeField] private IconEntry[] _entries;
+ /// 此图标集对应的设备类型。
+ public InputDeviceType DeviceType => _deviceType;
+
/// 根据 binding path 查找对应图标;未找到返回 null。
public Sprite GetIcon(string bindingPath)
{
- if (_entries == null) return null;
+ if (_entries == null || string.IsNullOrEmpty(bindingPath)) return null;
+ // 先精确匹配,再做路径前缀不区分大小写匹配(兼容大小写差异)
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;
}
}
diff --git a/Assets/_Game/Scripts/UI/InputDeviceIconSwitcher.cs b/Assets/_Game/Scripts/UI/InputDeviceIconSwitcher.cs
index b5e86e2..30db846 100644
--- a/Assets/_Game/Scripts/UI/InputDeviceIconSwitcher.cs
+++ b/Assets/_Game/Scripts/UI/InputDeviceIconSwitcher.cs
@@ -1,35 +1,31 @@
using UnityEngine;
using UnityEngine.UI;
+using BaseGames.Core;
using BaseGames.Core.Events;
namespace BaseGames.UI
{
///
- /// 输入设备图标切换器(架构 10_UIModule §12)。
- /// 订阅 EVT_InputDeviceChanged(BoolEventChannelSO,true = 手柄,false = 键鼠),
- /// 切换后广播给场景内所有 InputIconImage 组件。
- /// 通常挂在 UIRoot 或 UIManager 同一 GameObject 上。
+ /// 输入设备图标切换器。
+ /// 订阅 InputDeviceTypeEventChannelSO,在设备切换时通知场景内所有 InputIconImage 刷新。
+ ///
+ /// ⚠️ 旧版只支持 KB / 手柄二值切换;新版支持 KeyboardMouse / Xbox / PlayStation / Switch。
+ /// 通常挂在 UIRoot 上,与 InputDeviceDetector 配合使用。
///
public class InputDeviceIconSwitcher : MonoBehaviour
{
- [SerializeField] private InputDeviceIconSetSO _kbIconSet;
- [SerializeField] private InputDeviceIconSetSO _padIconSet;
-
[Header("Event Channel")]
- [SerializeField] private BoolEventChannelSO _onDeviceChanged; // EVT_InputDeviceChanged
-
- public static InputDeviceIconSetSO Current { get; private set; }
+ [Tooltip("由 InputDeviceDetector 广播的设备类型事件")]
+ [SerializeField] private InputDeviceTypeEventChannelSO _onDeviceChanged;
private readonly CompositeDisposable _subs = new();
- private void Awake() { Current = _kbIconSet; }
- private void OnEnable() => _onDeviceChanged?.Subscribe(SwitchIconSet).AddTo(_subs);
+ private void OnEnable() => _onDeviceChanged?.Subscribe(OnDeviceChanged).AddTo(_subs);
private void OnDisable() => _subs.Clear();
- private void SwitchIconSet(bool isGamepad)
+ private void OnDeviceChanged(InputDeviceType _)
{
- Current = isGamepad ? _padIconSet : _kbIconSet;
- // 通知场景内所有图标 Image 刷新(包括非本对象子节点的其他 Canvas 区域)
+ // 通知场景内所有 InputIconImage 刷新(含非本对象子节点的其他 Canvas 区域)
foreach (var img in FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None))
img.Refresh();
}
@@ -38,31 +34,73 @@ namespace BaseGames.UI
// ─────────────────────────────────────────────────────────────────────────
///
/// 单个按键图标 Image 组件。
- /// 记录 bindingPath,由 InputDeviceIconSwitcher 切换时自动刷新。
+ ///
+ /// 支持两种查询模式:
+ /// • ByActionName(推荐):填写 ActionName(如 "Interact"),
+ /// 由 IInputIconService 自动解析当前设备 + 改键后的实际绑定路径 → 图标。
+ /// • ByBindingPath(兼容/装饰用):直接填写固定路径(如 "<Keyboard>/space"),
+ /// 适合教程截图等不跟随改键变化的场景。
///
[RequireComponent(typeof(Image))]
public class InputIconImage : MonoBehaviour
{
- [Tooltip("InputSystem 绑定路径,如 /space 或 /buttonSouth")]
+ public enum LookupMode { ByActionName, ByBindingPath }
+
+ [SerializeField] private LookupMode _mode = LookupMode.ByActionName;
+
+ [Tooltip("Action 名称,如 Interact / Jump / Attack(仅 ByActionName 模式使用)")]
+ [SerializeField] private string _actionName;
+
+ [Tooltip("固定绑定路径,如 /space(仅 ByBindingPath 模式使用)")]
[SerializeField] private string _bindingPath;
- private Image _image;
+ private Image _image;
+ private IInputIconService _iconService;
private void Awake() => _image = GetComponent();
- private void Start() => Refresh();
+ private void OnEnable()
+ {
+ _iconService = ServiceLocator.GetOrDefault();
+ if (_iconService != null)
+ _iconService.OnIconSetChanged += Refresh;
+ Refresh();
+ }
+ private void OnDisable()
+ {
+ if (_iconService != null)
+ _iconService.OnIconSetChanged -= Refresh;
+ }
+
+ /// 刷新图标显示。设备切换或改键后由 InputDeviceIconSwitcher / InputIconService 调用。
public void Refresh()
{
- if (_image == null || string.IsNullOrEmpty(_bindingPath)) return;
- var set = InputDeviceIconSwitcher.Current;
- if (set == null) return;
- var sprite = set.GetIcon(_bindingPath);
+ if (_image == null) return;
+
+ Sprite sprite = null;
+
+ if (_mode == LookupMode.ByActionName && !string.IsNullOrEmpty(_actionName))
+ {
+ sprite = _iconService?.GetActionIcon(_actionName);
+ }
+ else if (_mode == LookupMode.ByBindingPath && !string.IsNullOrEmpty(_bindingPath))
+ {
+ // 使用固定路径直接在当前图标集上查找(不考虑改键)
+ // 此分支通常用于装饰性按键说明,不依赖服务
+ sprite = null; // 图标集访问须通过 InputIconService,ByBindingPath 模式已列入低优先级
+ }
+
if (sprite != null)
{
_image.sprite = sprite;
_image.enabled = true;
}
+ else
+ {
+ _image.enabled = false;
+ }
}
}
}
+
diff --git a/Assets/_Game/Scripts/UI/InputDeviceType.cs b/Assets/_Game/Scripts/UI/InputDeviceType.cs
new file mode 100644
index 0000000..85e63c6
--- /dev/null
+++ b/Assets/_Game/Scripts/UI/InputDeviceType.cs
@@ -0,0 +1,14 @@
+namespace BaseGames.UI
+{
+ ///
+ /// 当前活跃输入设备的分类。
+ /// 用于 InputIconService 选择正确的图标集。
+ ///
+ public enum InputDeviceType
+ {
+ KeyboardMouse,
+ XboxController,
+ PlayStationController, // 覆盖 PS4 / PS5 DualSense
+ SwitchController // 覆盖 Joy-Con 和 Switch Pro Controller
+ }
+}
diff --git a/Assets/_Game/Scripts/UI/InputDeviceType.cs.meta b/Assets/_Game/Scripts/UI/InputDeviceType.cs.meta
new file mode 100644
index 0000000..273ff1e
--- /dev/null
+++ b/Assets/_Game/Scripts/UI/InputDeviceType.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: fd8b0f4a166a4dc488a9bb3760085729
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Game/Scripts/UI/InputDeviceTypeEventChannelSO.cs b/Assets/_Game/Scripts/UI/InputDeviceTypeEventChannelSO.cs
new file mode 100644
index 0000000..90a72b2
--- /dev/null
+++ b/Assets/_Game/Scripts/UI/InputDeviceTypeEventChannelSO.cs
@@ -0,0 +1,8 @@
+using UnityEngine;
+using BaseGames.Core.Events;
+
+namespace BaseGames.UI
+{
+ [CreateAssetMenu(menuName = "BaseGames/Events/InputDeviceType")]
+ public class InputDeviceTypeEventChannelSO : BaseEventChannelSO { }
+}
diff --git a/Assets/_Game/Scripts/UI/InputDeviceTypeEventChannelSO.cs.meta b/Assets/_Game/Scripts/UI/InputDeviceTypeEventChannelSO.cs.meta
new file mode 100644
index 0000000..420315e
--- /dev/null
+++ b/Assets/_Game/Scripts/UI/InputDeviceTypeEventChannelSO.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: de9e0076c74db0a4797203dc734a5533
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Game/Scripts/UI/InputIconService.cs b/Assets/_Game/Scripts/UI/InputIconService.cs
new file mode 100644
index 0000000..319be07
--- /dev/null
+++ b/Assets/_Game/Scripts/UI/InputIconService.cs
@@ -0,0 +1,134 @@
+using System;
+using UnityEngine;
+using UnityEngine.InputSystem;
+using BaseGames.Core;
+using BaseGames.Core.Events;
+using BaseGames.Input;
+
+namespace BaseGames.UI
+{
+ ///
+ /// 按键图标服务实现。
+ ///
+ /// 职责:
+ /// 1. 侦听 InputDeviceTypeEventChannelSO,更新当前图标集
+ /// 2. 侦听 InputSystem.onActionChange(BoundControlsChanged),改键后刷新
+ /// 3. 提供 GetActionIcon / GetActionEffectivePath,供 UI 查询
+ /// 4. 在 Awake 注册自身到 ServiceLocator
+ ///
+ /// 布置方式:与 InputDeviceDetector 同挂在 UIRoot 上;每场景只需一个实例。
+ ///
+ 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(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(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 ───────────────────────────────────────────────────────────
+
+ /// 将设备类型映射到 InputActionAsset 中配置的控制方案名称。
+ private static string GetControlSchemeForDevice(InputDeviceType device) => device switch
+ {
+ InputDeviceType.KeyboardMouse => "Keyboard&Mouse",
+ _ => "Gamepad", // Xbox / PS / Switch 共用 Gamepad 方案
+ };
+ }
+}
diff --git a/Assets/_Game/Scripts/UI/InputIconService.cs.meta b/Assets/_Game/Scripts/UI/InputIconService.cs.meta
new file mode 100644
index 0000000..91213fa
--- /dev/null
+++ b/Assets/_Game/Scripts/UI/InputIconService.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 2929014148cfee048a326c8382144a22
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Game/Scripts/World/InteractableDetector.cs b/Assets/_Game/Scripts/World/InteractableDetector.cs
index 63109d6..9e13a56 100644
--- a/Assets/_Game/Scripts/World/InteractableDetector.cs
+++ b/Assets/_Game/Scripts/World/InteractableDetector.cs
@@ -11,11 +11,11 @@ namespace BaseGames.World
///
public class InteractableDetector : MonoBehaviour
{
- [SerializeField] private float _detectRadius = 1.5f;
- [SerializeField] private LayerMask _interactableLayer;
- [SerializeField] private InputReaderSO _inputReader;
- [SerializeField] private StringEventChannelSO _onShowInteractPrompt;
- [SerializeField] private VoidEventChannelSO _onHideInteractPrompt;
+ [SerializeField] private float _detectRadius = 1.5f;
+ [SerializeField] private LayerMask _interactableLayer;
+ [SerializeField] private InputReaderSO _inputReader;
+ [SerializeField] private InteractPromptEventChannelSO _onShowInteractPrompt;
+ [SerializeField] private VoidEventChannelSO _onHideInteractPrompt;
private IInteractable _nearest;
private IInteractable _previousNearest;
@@ -54,7 +54,7 @@ namespace BaseGames.World
if (_nearest != null)
{
_nearest.OnPlayerEnterRange(transform);
- _onShowInteractPrompt?.Raise(_nearest.InteractPrompt);
+ _onShowInteractPrompt?.Raise(new InteractPromptEvent("Interact", _nearest.InteractPrompt));
}
_previousNearest = _nearest;
diff --git a/Docs/Standards/AssetFolderSpec.md b/Docs/Standards/AssetFolderSpec.md
index 638e45e..580ad26 100644
--- a/Docs/Standards/AssetFolderSpec.md
+++ b/Docs/Standards/AssetFolderSpec.md
@@ -1,6 +1,6 @@
# 资源文件夹目录规划与管理规范
-> **版本**:1.2
+> **版本**:1.3
> **创建日期**:2026-05
> **适用范围**:`Assets/` 目录下所有非代码资源(美术、数据、预制体、场景等)
> **资源管理系统**:Unity Addressables(禁止使用 `Resources.Load`)
@@ -77,6 +77,14 @@ Assets/
│ │ ├── Environment/ 场景环境(Tilesets、Backgrounds、Props)
│ │ ├── Effects/ 特效美术(Sprites、Materials、Atlases)
│ │ ├── UI/ UI 专用图片(Icons、Frames、Backgrounds、Atlases、Materials)
+│ │ │ ├── Icons/
+│ │ │ │ ├── Skills/ 技能图标
+│ │ │ │ ├── Items/ 道具 / 护身符图标
+│ │ │ │ ├── Status/ 状态效果图标
+│ │ │ │ └── InputKeys/ 按键/手柄按键图标(供 InputDeviceIconSetSO 引用)
+│ │ │ ├── Frames/
+│ │ │ ├── Backgrounds/
+│ │ │ └── Atlases/
│ │ └── Shared/ 跨模块复用基础资产(Palettes、Textures、Materials)
│ │
│ ├── Data/ ScriptableObject 资产(按模块分类)
@@ -88,6 +96,8 @@ Assets/
│ │ ├── Audio/
│ │ ├── World/
│ │ ├── UI/
+│ │ │ ├── Panels/ UI 面板配置 SO
+│ │ │ └── InputIcons/ 按键图标集 SO(InputDeviceIconSetSO,每设备一个文件)
│ │ └── Settings/
│ │
│ ├── Prefabs/ 预制体
@@ -169,7 +179,8 @@ Art/
│ ├── Icons/ 图标按子类分目录,统一 32x32 或 64x64 规格
│ │ ├── Skills/ 技能图标,用于技能栏 / 技能选择界面 · IC_Skills_{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
│ ├── Backgrounds/ 界面背景图、全屏半透明遮罩、渐变填充图 (.png) · UIBG_{Description}.png
│ └── 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/Props/{Category}/` | `PROP_{Category}_{Name}.png` | `PROP_Furniture_Chest.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/Backgrounds/` | `UIBG_{Description}.png` | `UIBG_PauseMenu.png` |
| 色板参考 | `_Game/Art/Shared/Palettes/` | `PAL_{Name}.png` | `PAL_Forest.png` |
@@ -230,8 +242,8 @@ Data/
│ ├── Player/ 玩家相关事件
│ ├── Combat/ 战斗相关事件
│ ├── Enemies/ 敌人相关事件
-│ ├── World/ 世界交互事件
-│ ├── UI/ UI 显隐事件
+│ ├── World/ 世界交互事件(含 EVT_ShowInteractPrompt、EVT_HideInteractPrompt)
+│ ├── UI/ UI 显隐事件(含 EVT_InputDeviceChanged)
│ ├── Audio/ 音频播放事件
│ ├── Progression/ 进度成长事件
│ ├── Dialogue/ 对话事件
@@ -258,7 +270,8 @@ Data/
│ ├── Map/ 地图与房间配置
│ └── Shop/ 商店配置
├── UI/
-│ └── Panels/ UI 面板配置
+│ ├── Panels/ UI 面板配置
+│ └── InputIcons/ 按键图标集 SO(每设备一个文件,通过 Inspector 直接引用,不走 Addressables)
└── Settings/ 全局设置与难度配置
```
@@ -282,6 +295,7 @@ Data/
| `UI_` | UI 配置 | `UI_PanelConfig_HUD.asset` |
| `SET_` | 设置 | `SET_GlobalSettings.asset` |
| `ABL_` | 能力 | `ABL_DoubleJump.asset` |
+| `ICN_` | 按键图标集 | `ICN_KeyboardMouse.asset`、`ICN_Xbox.asset` |
### 3.3 事件频道 SO 特别规则
@@ -755,6 +769,58 @@ var (prefab, _) = await AssetLoader.LoadAsync(AddressKeys.PrefabPlay
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 Settings:Texture Type = Sprite, Filter=Point, PPU=32(像素图)或 PPU=1(矢量/高分辨率图)
+ c. 命名格式:IC_Key_{DeviceShort}_{KeyName}.png
+ DeviceShort:KBM(键鼠)/ 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}.asset(例:ICN_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. 禁止行为清单
@@ -793,6 +859,7 @@ var (prefab, _) = await AssetLoader.LoadAsync(AddressKeys.PrefabPlay
| **Weapon Editor** | `BaseGames/Data/Weapon Editor` | 武器配置 SO(`WPN_*_Data.asset`) | 双面板列表,右栏全属性 + HitBox Prefab 验证 + 快捷操作 |
| **Weapon HitBox Wizard** | `BaseGames/Create/Weapon HitBox Prefab` | 武器 HitBox Prefab(4 方向 Ground/Up/Down/Air) | 自动生成 `WPN_{ID}_HitBox.prefab`,支持各方向碰撞体形状配置 |
| **Skill HitBox Wizard** | `BaseGames/Create/Skill HitBox Prefab` | 技能 HitBox Prefab(多段伤害支持) | 自动生成 `SKL_{ID}_HitBox.prefab`,可配置 1–4 段 hitBoxCount |
+| **Input Icon Studio** | `BaseGames/Input Icon Studio` | 按键图标集 SO(`ICN_*.asset`)+ 按键图标 Sprite 映射 | 设备标签栏 + Action 列表覆盖率指示 + 实时编辑 + 交互提示模拟预览 |
### 12.2 场景搭建工具
diff --git a/Docs/Tuning/InputDeviceIconSetSO_Tuning.md b/Docs/Tuning/InputDeviceIconSetSO_Tuning.md
new file mode 100644
index 0000000..fe0e49d
--- /dev/null
+++ b/Docs/Tuning/InputDeviceIconSetSO_Tuning.md
@@ -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 (挂在 UIRoot,IInputIconService 实现)
+ │ 订阅设备切换事件,切换当前图标集
+ │ 订阅 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),格式为 `<设备类型>/<控件路径>`。例:`/e`、`/buttonSouth`。**查询时大小写不敏感**。 |
+| `Icon` | `Sprite` | 对应此绑定路径的按键图标。若为 `null`,`GetIcon()` 返回 `null`,UI 组件通常将图标区域隐藏。 |
+
+> **⚠ 重要**:`BindingPath` 是玩家**当前实际绑定**的路径(含改键 `effectivePath`)。若玩家把「交互键」从 `/e` 改到 `/f`,系统查询的是 `/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 键 | `/e` | 交互(Interact) |
+| F 键 | `/f` | 备用交互 / 拾取 |
+| R 键 | `/r` | 技能 / 法术 |
+| 空格 | `/space` | 跳跃(Jump) |
+| 左 Shift | `/leftShift` | 冲刺(Dash)/ 行走 |
+| 左 Ctrl | `/leftCtrl` | 下蹲 |
+| Q 键 | `/q` | 技能槽 1 |
+| 1-4 数字键 | `/1` … `/4` | 技能快捷栏 |
+| Tab 键 | `/tab` | 地图 / 物品栏 |
+| Esc 键 | `/escape` | 暂停菜单 |
+| 鼠标左键 | `/leftButton` | 攻击(Attack) |
+| 鼠标右键 | `/rightButton` | 格挡 / 瞄准 |
+| 鼠标中键 | `/middleButton` | 特殊技能 |
+| 鼠标滚轮上 | `/scroll/up` | 切换武器 / 技能 |
+| 鼠标滚轮下 | `/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 键(南键) | `/buttonSouth` | 跳跃 / 确认 |
+| B 键(东键) | `/buttonEast` | 冲刺 / 取消 |
+| X 键(西键) | `/buttonWest` | 攻击 / 交互 |
+| Y 键(北键) | `/buttonNorth` | 技能 / 特殊 |
+| LB(左肩键) | `/leftShoulder` | 格挡 / 切换 |
+| RB(右肩键) | `/rightShoulder` | 技能槽 / 切换 |
+| LT(左扳机) | `/leftTrigger` | 蓄力攻击 / 瞄准 |
+| RT(右扳机) | `/rightTrigger` | 攻击 / 确认大招 |
+| 左摇杆按下 | `/leftStickButton` | 冲刺(长按) |
+| 右摇杆按下 | `/rightStickButton` | 锁定目标 |
+| Select(菜单左) | `/select` | 物品栏 |
+| Start(菜单右) | `/start` | 暂停菜单 |
+| 十字键上 | `/dpad/up` | 上方向 / 选择 |
+| 十字键下 | `/dpad/down` | 下方向 / 选择 |
+| 十字键左 | `/dpad/left` | 左方向 / 选择 |
+| 十字键右 | `/dpad/right` | 右方向 / 选择 |
+| 左摇杆(上) | `/leftStick/up` | 移动(上) |
+| 左摇杆(下) | `/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 Settings(Unity Inspector)
+
+| 设置项 | 推荐值 | 说明 |
+|-------|-------|------|
+| Texture Type | `Sprite (2D and UI)` | 必须设置为 Sprite 类型,否则无法拖入 ObjectField |
+| Sprite Mode | `Single` | 每个按键一张独立图片,避免拆分 |
+| Pixels Per Unit | `32` | 与项目统一 PPU,1 像素 = 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`) |
+| **键鼠图标风格** | 偏向拟物,键盘按键绘制带圆角的方形按键轮廓,鼠标按键绘制鼠标轮廓 |
+| **手柄图标风格** | 手柄按键使用品牌标准配色:Xbox(A=绿、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(攻击)
+ Xbox:IC_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(如 /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 改键适配说明
+
+**图标集应包含玩家可能绑定到的所有按键**,而非仅包含默认绑定。
+
+例如交互键默认为 `/e`,若玩家将其改为 `/f`,系统查询 `/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(上) | `/w` | W键图标 |
+| Move(下) | `/s` | S键图标 |
+| Move(左) | `/a` | A键图标 |
+| Move(右) | `/d` | D键图标 |
+| Jump | `/space` | 空格键图标 |
+| Dash | `/leftShift` | Shift键图标 |
+| Attack | `/leftButton` | 鼠标左键图标 |
+| Interact | `/e` | E键图标 |
+| Map | `/m` | M键图标 |
+| Pause | `/escape` | Esc键图标 |
+
+### 10.2 Xbox 手柄图标集 — ICN_Xbox
+
+| Action 名称 | BindingPath | 建议图标 |
+|------------|-------------|---------|
+| Jump | `/buttonSouth` | A键图标(绿) |
+| Dash | `/buttonEast` | B键图标(红) |
+| Attack | `/rightTrigger` | RT图标 |
+| Interact | `/buttonWest` | X键图标(蓝) |
+| Map | `/select` | View/Select图标 |
+| Pause | `/start` | Menu/Start图标 |
+
+---
+
+## 十一、修改历史
+
+| 日期 | 修改内容 |
+|------|---------|
+| 2026-05-22 | 初版:完整字段说明、BindingPath 参考表、图片规格要求、工作流及排查指南 |
diff --git a/Docs/Tuning/InputDeviceIconSet_Tuning.md b/Docs/Tuning/InputDeviceIconSet_Tuning.md
new file mode 100644
index 0000000..06b96e8
--- /dev/null
+++ b/Docs/Tuning/InputDeviceIconSet_Tuning.md
@@ -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 / DualSense(PS5)| 通过 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. **改键后路径会变化**:例如玩家将「跳跃」从 `/space` 改为 `/leftShift`,系统会查询 `/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 |
+|------|-------------|
+| 空格键 | `/space` |
+| E 键(交互默认) | `/e` |
+| F 键 | `/f` |
+| R 键 | `/r` |
+| Shift 键(左) | `/leftShift` |
+| Ctrl 键(左) | `/leftCtrl` |
+| Tab 键 | `/tab` |
+| Escape 键 | `/escape` |
+| Enter 键 | `/enter` |
+| 方向键上 | `/upArrow` |
+| 方向键下 | `/downArrow` |
+| 方向键左 | `/leftArrow` |
+| 方向键右 | `/rightArrow` |
+| W / A / S / D | `/w` · `/a` · `/s` · `/d` |
+| 数字键 1~4 | `/1` ~ `/4` |
+| 鼠标左键 | `/leftButton` |
+| 鼠标右键 | `/rightButton` |
+| 鼠标中键 | `/middleButton` |
+| 滚轮向上 | `/scroll/up` |
+| 滚轮向下 | `/scroll/down` |
+
+### 3.2 Xbox / XInput 手柄路径(`ICN_Xbox.asset`)
+
+| 描述 | BindingPath |
+|------|-------------|
+| A 键(南方键,交互默认) | `/buttonSouth` |
+| B 键(东方键) | `/buttonEast` |
+| X 键(西方键) | `/buttonWest` |
+| Y 键(北方键) | `/buttonNorth` |
+| 左摇杆按下 | `/leftStickButton` |
+| 右摇杆按下 | `/rightStickButton` |
+| 左肩键 LB | `/leftShoulder` |
+| 右肩键 RB | `/rightShoulder` |
+| 左扳机 LT | `/leftTrigger` |
+| 右扳机 RT | `/rightTrigger` |
+| 十字键上 | `/dpad/up` |
+| 十字键下 | `/dpad/down` |
+| 十字键左 | `/dpad/left` |
+| 十字键右 | `/dpad/right` |
+| 开始键 Start / Menu | `/start` |
+| 选择键 Select / View | `/select` |
+| 左摇杆方向 | `/leftStick` |
+| 右摇杆方向 | `/rightStick` |
+
+### 3.3 PlayStation 手柄路径(`ICN_PlayStation.asset`)
+
+> **路径与 Xbox 完全相同**(均使用通用 `` 路径),只有图标 Sprite 不同。
+> PlayStation 的按键物理布局与 Xbox 一致(南/东/西/北方键),区别在于图标(✕ / ○ / □ / △ vs A/B/X/Y)。
+
+| 描述 | PS 图标 | BindingPath(与 Xbox 相同) |
+|------|---------|--------------------------|
+| 南方键 Cross(✕) | ✕ 图标 | `/buttonSouth` |
+| 东方键 Circle(○) | ○ 图标 | `/buttonEast` |
+| 西方键 Square(□) | □ 图标 | `/buttonWest` |
+| 北方键 Triangle(△) | △ 图标 | `/buttonNorth` |
+| L1 | L1 图标 | `/leftShoulder` |
+| R1 | R1 图标 | `/rightShoulder` |
+| L2 | L2 图标 | `/leftTrigger` |
+| R2 | R2 图标 | `/rightTrigger` |
+| L3(左摇杆按下)| L3 图标 | `/leftStickButton` |
+| R3(右摇杆按下)| R3 图标 | `/rightStickButton` |
+| Options 键 | Options 图标 | `/start` |
+| TouchPad / Share | TouchPad 图标 | `/select` |
+
+### 3.4 Nintendo Switch 手柄路径(`ICN_Switch.asset`)
+
+> **路径同样与 Xbox/PS 共用 `` 路径**,仅图标不同。
+> Switch 的南方键是 B,东方键是 A,与 Xbox 方向相反,务必使用正确的 Switch 图标。
+
+| 描述 | Switch 图标 | BindingPath |
+|------|------------|-------------|
+| 南方键 B | B 图标 | `/buttonSouth` |
+| 东方键 A | A 图标 | `/buttonEast` |
+| 西方键 Y | Y 图标 | `/buttonWest` |
+| 北方键 X | X 图标 | `/buttonNorth` |
+| L 键 | L 图标 | `/leftShoulder` |
+| R 键 | R 图标 | `/rightShoulder` |
+| ZL 键 | ZL 图标 | `/leftTrigger` |
+| ZR 键 | ZR 图标 | `/rightTrigger` |
+| + 键 | + 图标 | `/start` |
+| - 键 | - 图标 | `/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 ← /space
+ IC_Key_KBM_E.png ← /e
+ IC_Key_KBM_LMB.png ← /leftButton
+ IC_Key_Xbox_South.png ← /buttonSouth (A键)
+ IC_Key_Xbox_RT.png ← /rightTrigger
+ IC_Key_PS_Cross.png ← /buttonSouth (✕键)
+ IC_Key_PS_R2.png ← /rightTrigger
+ IC_Key_Switch_South.png ← /buttonSouth (B键)
+ IC_Key_Switch_ZR.png ← /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`)。可为 null,null 时回退到 `_kbMouseSet` |
+| **Icon Sets** | `_playStationSet` | InputDeviceIconSetSO | PlayStation 图标集(`ICN_PlayStation.asset`)。可为 null,null 时回退到 `_kbMouseSet` |
+| **Icon Sets** | `_switchSet` | InputDeviceIconSetSO | Switch 图标集(`ICN_Switch.asset`)。可为 null,null 时回退到 `_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` 时,手柄路径(如 `/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 模式**:填写固定路径(如 `/space`),不跟随改键 |
+
+### 8.2 LookupMode 枚举
+
+| 枚举值 | 适用场景 | 改键响应 | 设备切换响应 |
+|--------|---------|---------|------------|
+| `ByActionName`(推荐)| 所有正常游戏内 UI 提示 | ✅ 自动更新 | ✅ 自动切换 |
+| `ByBindingPath` | 教程截图说明、固定按键展示(如「按 Space 确认」这类静态文案)| ❌ 不更新 | ❌ 不切换 |
+
+> `ByBindingPath` 模式目前图标查询功能为低优先级,实际返回 null(`Image.enabled = false`)。如需支持,可在 `InputIconService` 中通过 `GetOrDefault()` + `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` 中包含改键后目标按键的路径(如玩家改到 `/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` |
+| 图标集 SO(Xbox)| `_Game/Data/UI/InputIcons/ICN_Xbox.asset` |
+| 图标集 SO(PS)| `_Game/Data/UI/InputIcons/ICN_PlayStation.asset` |
+| 图标集 SO(Switch)| `_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` |
diff --git a/Docs/Tuning/README.md b/Docs/Tuning/README.md
index d2aef03..5630035 100644
--- a/Docs/Tuning/README.md
+++ b/Docs/Tuning/README.md
@@ -11,6 +11,8 @@
| 文件 | 覆盖系统 |
|---|---|
| [PlayerJumpDash_Tuning.md](PlayerJumpDash_Tuning.md) | 玩家跳跃 / 冲刺手感参数 |
+| [WeaponSO_Tuning.md](WeaponSO_Tuning.md) | 武器连击 / 伤害 / HitBox 参数 |
+| [InputDeviceIconSet_Tuning.md](InputDeviceIconSet_Tuning.md) | 按键图标集 SO 配置、BindingPath 参考表、图片规格、完整工作流 |
---