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 参考表、图片规格、完整工作流 | ---