Add InputDeviceIconSetSO configuration guide and related documentation
- Created a new markdown file detailing the configuration of InputDeviceIconSetSO. - Included sections on system architecture, field explanations, image specifications, and complete workflow from setup to runtime. - Documented the automatic device recognition logic and provided troubleshooting for common issues. - Added references to relevant files and scripts for easier navigation.
This commit is contained in:
22
Assets/_Game/Scripts/Core/Events/InteractPromptEvent.cs
Normal file
22
Assets/_Game/Scripts/Core/Events/InteractPromptEvent.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
namespace BaseGames.Core.Events
|
||||
{
|
||||
/// <summary>
|
||||
/// 交互提示事件负载。
|
||||
/// 由 InteractableDetector 广播,包含触发动作名称和显示文本,
|
||||
/// UI 层(InteractPromptWidget)据此查询图标并显示提示。
|
||||
/// </summary>
|
||||
public readonly struct InteractPromptEvent
|
||||
{
|
||||
/// <summary>InputSystem Action 名称,如 "Interact"。用于查询按键图标。</summary>
|
||||
public readonly string ActionName;
|
||||
|
||||
/// <summary>交互物提供的说明文本,如 "对话"、"存档"、"传送"。</summary>
|
||||
public readonly string LabelText;
|
||||
|
||||
public InteractPromptEvent(string actionName, string labelText)
|
||||
{
|
||||
ActionName = actionName;
|
||||
LabelText = labelText;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/Core/Events/InteractPromptEvent.cs.meta
Normal file
11
Assets/_Game/Scripts/Core/Events/InteractPromptEvent.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9eccce8fdbd936b46a467d078957a387
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,7 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Core.Events
|
||||
{
|
||||
[CreateAssetMenu(menuName = "BaseGames/Events/InteractPrompt")]
|
||||
public class InteractPromptEventChannelSO : BaseEventChannelSO<InteractPromptEvent> { }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5e6db212f7619344588f054af0c6330a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -31,7 +31,8 @@
|
||||
"BaseGames.Skills",
|
||||
"BaseGames.World.Map",
|
||||
"BaseGames.EventChain",
|
||||
"BaseGames.VFX"
|
||||
"BaseGames.VFX",
|
||||
"Unity.InputSystem"
|
||||
],
|
||||
"includePlatforms": [
|
||||
"Editor"
|
||||
|
||||
@@ -37,6 +37,7 @@ namespace BaseGames.Editor
|
||||
CreateAsset<StringEventChannelSO> ("Core", "EVT_SceneLoaded");
|
||||
CreateAsset<VoidEventChannelSO> ("Core", "EVT_FadeInRequest");
|
||||
CreateAsset<VoidEventChannelSO> ("Core", "EVT_FadeOutRequest");
|
||||
CreateAsset<VoidEventChannelSO> ("Core", "EVT_SceneWorldStateRestored"); // 场景加载完毕、世界状态恢复后触发;场景物体在此订阅并应用存档状态,淡入前保证画面正确
|
||||
|
||||
// ── 难度 ──────────────────────────────────────────────────────────
|
||||
CreateAsset<DifficultyChangedEventChannel>("Difficulty", "EVT_DifficultyChanged");
|
||||
@@ -99,14 +100,18 @@ namespace BaseGames.Editor
|
||||
CreateAsset<VoidEventChannelSO> ("World", "EVT_CheckpointReached");
|
||||
CreateAsset<StringEventChannelSO> ("World", "EVT_DoorOpened"); // 开门/交互机关(钥匙、机关等)触发自动存档
|
||||
CreateAsset<StringEventChannelSO> ("World", "EVT_ItemPickup"); // 道具/收集品获取(itemId)
|
||||
CreateAsset<StringEventChannelSO> ("World", "EVT_CollectiblePickup"); // 关键物品拾取(护符、道具等)触发存档;AutoSaveService / QuestManager / EventChainManager 监听
|
||||
CreateAsset<StringEventChannelSO> ("World", "EVT_CollectibleSaved"); // 持久化记录收集品(collectibleId)
|
||||
CreateAsset<StringEventChannelSO> ("World", "EVT_RoomEntered"); // 玩家进入新房间(roomId)
|
||||
CreateAsset<StringEventChannelSO> ("World", "EVT_RegionChanged"); // 玩家首次进入新区域(regionId)
|
||||
CreateAsset<StringEventChannelSO> ("World", "EVT_RevealRegion"); // 触发地图区域揭露(regionId)
|
||||
CreateAsset<StringEventChannelSO> ("World", "EVT_MapUpdated"); // 房间首次探索/标注时刷新(roomId);MapManager 发布,MapPanel 订阅
|
||||
CreateAsset<StringEventChannelSO> ("World", "EVT_ChallengeCompleted"); // 挑战房间通关(challengeId)
|
||||
CreateAsset<StringEventChannelSO> ("World", "EVT_ChallengeFailed"); // 挑战房间失败(challengeId)
|
||||
CreateAsset<LiquidEventChannelSO> ("World", "EVT_LiquidEntered"); // 玩家进入液体区域
|
||||
CreateAsset<LiquidEventChannelSO> ("World", "EVT_LiquidExited"); // 玩家离开液体区域
|
||||
CreateAsset<BaseGames.World.WorldMarkerEventChannelSO>("World", "EVT_WorldMarkerActivated"); // 导航标记点激活(地图图标显示)
|
||||
CreateAsset<BaseGames.World.WorldMarkerEventChannelSO>("World", "EVT_WorldMarkerDeactivated"); // 导航标记点失活(地图图标隐藏)
|
||||
|
||||
// ── 对话/商店 ─────────────────────────────────────────────────────
|
||||
CreateAsset<ShopPurchaseEventChannelSO> ("Dialogue", "EVT_ShopPurchase");
|
||||
|
||||
8
Assets/_Game/Scripts/Editor/Input.meta
Normal file
8
Assets/_Game/Scripts/Editor/Input.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 52933b4810ae6654c962a93708b64a8f
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
467
Assets/_Game/Scripts/Editor/Input/InputDeviceIconSetSOEditor.cs
Normal file
467
Assets/_Game/Scripts/Editor/Input/InputDeviceIconSetSOEditor.cs
Normal file
@@ -0,0 +1,467 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEditor;
|
||||
using UnityEditor.UIElements;
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
using UnityEngine.UIElements;
|
||||
using BaseGames.UI;
|
||||
|
||||
namespace BaseGames.Editor.Input
|
||||
{
|
||||
/// <summary>
|
||||
/// InputDeviceIconSetSO 自定义 Inspector。
|
||||
///
|
||||
/// 布局(从上到下):
|
||||
/// ① 设备类型徽章 + 覆盖率芯片
|
||||
/// ② 操作按钮工具栏(从 Action Asset 自动填充 / 打开 Studio)
|
||||
/// ③ 按键图标条目表(Path | Icon Sprite | 48px 预览 | 删除按钮)
|
||||
/// ④ + 新增条目 按钮
|
||||
/// </summary>
|
||||
[CustomEditor(typeof(InputDeviceIconSetSO))]
|
||||
public class InputDeviceIconSetSOEditor : UnityEditor.Editor
|
||||
{
|
||||
private const string UssPath = "Assets/_Game/Scripts/Editor/UIToolkit/Editor.uss";
|
||||
|
||||
// 预设绑定路径快捷菜单(按设备类型)
|
||||
private static readonly Dictionary<InputDeviceType, string[]> s_CommonPaths = new()
|
||||
{
|
||||
[InputDeviceType.KeyboardMouse] = new[]
|
||||
{
|
||||
"<Keyboard>/e", "<Keyboard>/f", "<Keyboard>/r", "<Keyboard>/space",
|
||||
"<Keyboard>/enter", "<Keyboard>/escape", "<Keyboard>/shift",
|
||||
"<Keyboard>/ctrl", "<Keyboard>/tab", "<Keyboard>/q", "<Keyboard>/g",
|
||||
"<Mouse>/leftButton", "<Mouse>/rightButton", "<Mouse>/middleButton"
|
||||
},
|
||||
[InputDeviceType.XboxController] = new[]
|
||||
{
|
||||
"<Gamepad>/buttonSouth", "<Gamepad>/buttonNorth",
|
||||
"<Gamepad>/buttonEast", "<Gamepad>/buttonWest",
|
||||
"<Gamepad>/leftShoulder", "<Gamepad>/rightShoulder",
|
||||
"<Gamepad>/leftTrigger", "<Gamepad>/rightTrigger",
|
||||
"<Gamepad>/start", "<Gamepad>/select",
|
||||
"<Gamepad>/leftStickPress", "<Gamepad>/rightStickPress"
|
||||
},
|
||||
[InputDeviceType.PlayStationController] = new[]
|
||||
{
|
||||
"<Gamepad>/buttonSouth", "<Gamepad>/buttonNorth",
|
||||
"<Gamepad>/buttonEast", "<Gamepad>/buttonWest",
|
||||
"<Gamepad>/leftShoulder", "<Gamepad>/rightShoulder",
|
||||
"<Gamepad>/leftTrigger", "<Gamepad>/rightTrigger",
|
||||
"<Gamepad>/start", "<Gamepad>/select"
|
||||
},
|
||||
[InputDeviceType.SwitchController] = new[]
|
||||
{
|
||||
"<Gamepad>/buttonSouth", "<Gamepad>/buttonNorth",
|
||||
"<Gamepad>/buttonEast", "<Gamepad>/buttonWest",
|
||||
"<Gamepad>/leftShoulder", "<Gamepad>/rightShoulder",
|
||||
"<Gamepad>/leftTrigger", "<Gamepad>/rightTrigger",
|
||||
"<Gamepad>/start", "<Gamepad>/select"
|
||||
}
|
||||
};
|
||||
|
||||
// ── 状态 ──────────────────────────────────────────────────────────────
|
||||
private SerializedProperty _entriesProp;
|
||||
private VisualElement _root;
|
||||
private VisualElement _tableContainer;
|
||||
private Label _coverageLabel;
|
||||
|
||||
// ── 生命周期 ──────────────────────────────────────────────────────────
|
||||
public override VisualElement CreateInspectorGUI()
|
||||
{
|
||||
_entriesProp = serializedObject.FindProperty("_entries");
|
||||
|
||||
_root = new VisualElement();
|
||||
var uss = AssetDatabase.LoadAssetAtPath<StyleSheet>(UssPath);
|
||||
if (uss != null) _root.styleSheets.Add(uss);
|
||||
|
||||
BuildHeader();
|
||||
BuildToolbar();
|
||||
BuildTable();
|
||||
BuildAddButton();
|
||||
|
||||
return _root;
|
||||
}
|
||||
|
||||
// ── 头部 ─────────────────────────────────────────────────────────────
|
||||
|
||||
private void BuildHeader()
|
||||
{
|
||||
var header = new VisualElement();
|
||||
header.style.flexDirection = FlexDirection.Row;
|
||||
header.style.alignItems = Align.Center;
|
||||
header.style.paddingLeft = 4;
|
||||
header.style.paddingRight = 4;
|
||||
header.style.paddingTop = 6;
|
||||
header.style.paddingBottom = 6;
|
||||
header.style.borderBottomWidth = 1;
|
||||
header.style.borderBottomColor = new StyleColor(new Color(0.5f, 0.5f, 0.5f, 0.25f));
|
||||
header.style.marginBottom = 6;
|
||||
|
||||
// 设备类型字段
|
||||
var deviceProp = serializedObject.FindProperty("_deviceType");
|
||||
var deviceField = new PropertyField(deviceProp, "设备类型");
|
||||
deviceField.style.flexGrow = 1;
|
||||
deviceField.RegisterValueChangeCallback(_ => RefreshCoverage());
|
||||
header.Add(deviceField);
|
||||
|
||||
// 覆盖率徽章
|
||||
_coverageLabel = new Label();
|
||||
_coverageLabel.AddToClassList("status-chip--ok");
|
||||
_coverageLabel.style.fontSize = 10;
|
||||
_coverageLabel.style.marginLeft = 8;
|
||||
RefreshCoverage();
|
||||
header.Add(_coverageLabel);
|
||||
|
||||
_root.Add(header);
|
||||
}
|
||||
|
||||
// ── 工具栏 ────────────────────────────────────────────────────────────
|
||||
|
||||
private void BuildToolbar()
|
||||
{
|
||||
var bar = new VisualElement();
|
||||
bar.AddToClassList("editor-toolbar");
|
||||
bar.style.marginBottom = 6;
|
||||
|
||||
// 从 InputActionAsset 自动填充路径
|
||||
var btnAutoFill = new Button(AutoFillFromActionAsset)
|
||||
{
|
||||
text = "⬇ 从 Action Asset 填充路径",
|
||||
tooltip = "扫描 InputReaderSO,自动为该设备控制方案添加所有绑定路径(Sprite 留空需手动指定)"
|
||||
};
|
||||
btnAutoFill.AddToClassList("wizard-factory-btn");
|
||||
bar.Add(btnAutoFill);
|
||||
|
||||
// 清空 null Sprite 条目
|
||||
var btnClean = new Button(RemoveEmptySpriteEntries)
|
||||
{
|
||||
text = "🧹 清理空 Sprite",
|
||||
tooltip = "移除 Sprite 为空的条目"
|
||||
};
|
||||
bar.Add(btnClean);
|
||||
|
||||
// 打开 Studio
|
||||
var btnStudio = new Button(() => InputIconStudioWindow.Open())
|
||||
{
|
||||
text = "🎨 打开 Icon Studio",
|
||||
tooltip = "打开完整的按键图标管理工作台"
|
||||
};
|
||||
btnStudio.AddToClassList("wizard-jump-btn");
|
||||
btnStudio.style.marginLeft = 8;
|
||||
bar.Add(btnStudio);
|
||||
|
||||
_root.Add(bar);
|
||||
}
|
||||
|
||||
// ── 条目表 ────────────────────────────────────────────────────────────
|
||||
|
||||
private void BuildTable()
|
||||
{
|
||||
// 表头
|
||||
var tableHead = new VisualElement();
|
||||
tableHead.style.flexDirection = FlexDirection.Row;
|
||||
tableHead.style.paddingLeft = 4;
|
||||
tableHead.style.paddingRight = 4;
|
||||
tableHead.style.paddingTop = 3;
|
||||
tableHead.style.paddingBottom = 3;
|
||||
tableHead.style.borderBottomWidth = 1;
|
||||
tableHead.style.borderBottomColor = new StyleColor(new Color(0.5f, 0.5f, 0.5f, 0.3f));
|
||||
tableHead.style.backgroundColor = new StyleColor(new Color(0.5f, 0.5f, 0.5f, 0.08f));
|
||||
|
||||
AddHeaderCell(tableHead, "绑定路径", flexGrow: 1f);
|
||||
AddHeaderCell(tableHead, "图标", flexGrow: 0.8f);
|
||||
AddHeaderCell(tableHead, "预览", width: 52f);
|
||||
AddHeaderCell(tableHead, "", width: 24f);
|
||||
_root.Add(tableHead);
|
||||
|
||||
// 条目容器
|
||||
_tableContainer = new VisualElement();
|
||||
_root.Add(_tableContainer);
|
||||
|
||||
RebuildRows();
|
||||
}
|
||||
|
||||
private void RebuildRows()
|
||||
{
|
||||
_tableContainer.Clear();
|
||||
serializedObject.Update();
|
||||
|
||||
for (int i = 0; i < _entriesProp.arraySize; i++)
|
||||
_tableContainer.Add(BuildRow(i));
|
||||
|
||||
RefreshCoverage();
|
||||
}
|
||||
|
||||
private VisualElement BuildRow(int index)
|
||||
{
|
||||
var entryProp = _entriesProp.GetArrayElementAtIndex(index);
|
||||
var pathProp = entryProp.FindPropertyRelative("BindingPath");
|
||||
var iconProp = entryProp.FindPropertyRelative("Icon");
|
||||
|
||||
var row = new VisualElement();
|
||||
row.style.flexDirection = FlexDirection.Row;
|
||||
row.style.alignItems = Align.Center;
|
||||
row.style.paddingTop = 2;
|
||||
row.style.paddingBottom = 2;
|
||||
row.style.paddingLeft = 4;
|
||||
row.style.paddingRight = 4;
|
||||
row.style.borderBottomWidth = 1;
|
||||
row.style.borderBottomColor = new StyleColor(new Color(0.5f, 0.5f, 0.5f, 0.12f));
|
||||
row.AddToClassList("list-item");
|
||||
|
||||
// ── 绑定路径 ──────────────────────────────────────────────────
|
||||
var pathField = new TextField { value = pathProp.stringValue, isDelayed = true };
|
||||
pathField.style.flexGrow = 1;
|
||||
pathField.style.flexShrink = 1;
|
||||
pathField.style.fontSize = 10;
|
||||
pathField.RegisterValueChangedCallback(evt =>
|
||||
{
|
||||
serializedObject.Update();
|
||||
_entriesProp.GetArrayElementAtIndex(index)
|
||||
.FindPropertyRelative("BindingPath").stringValue = evt.newValue;
|
||||
serializedObject.ApplyModifiedProperties();
|
||||
RefreshCoverage();
|
||||
});
|
||||
|
||||
// 路径快捷菜单按钮
|
||||
var pathRow = new VisualElement();
|
||||
pathRow.style.flexDirection = FlexDirection.Row;
|
||||
pathRow.style.flexGrow = 1;
|
||||
pathRow.style.alignItems = Align.Center;
|
||||
pathField.style.flexGrow = 1;
|
||||
pathRow.Add(pathField);
|
||||
|
||||
var menuBtn = new Button(() =>
|
||||
{
|
||||
var menu = new GenericMenu();
|
||||
var so = target as InputDeviceIconSetSO;
|
||||
if (so != null && s_CommonPaths.TryGetValue(so.DeviceType, out var paths))
|
||||
{
|
||||
foreach (var p in paths)
|
||||
{
|
||||
var captured = p;
|
||||
menu.AddItem(new GUIContent(p), false, () =>
|
||||
{
|
||||
serializedObject.Update();
|
||||
_entriesProp.GetArrayElementAtIndex(index)
|
||||
.FindPropertyRelative("BindingPath").stringValue = captured;
|
||||
serializedObject.ApplyModifiedProperties();
|
||||
pathField.value = captured;
|
||||
RefreshCoverage();
|
||||
});
|
||||
}
|
||||
}
|
||||
menu.ShowAsContext();
|
||||
}) { text = "▾" };
|
||||
menuBtn.style.width = 20;
|
||||
menuBtn.style.height = 18;
|
||||
menuBtn.style.fontSize = 9;
|
||||
menuBtn.style.paddingLeft = menuBtn.style.paddingRight = 0;
|
||||
menuBtn.tooltip = "常用路径快捷选择";
|
||||
pathRow.Add(menuBtn);
|
||||
row.Add(pathRow);
|
||||
|
||||
// ── Icon Sprite ───────────────────────────────────────────────
|
||||
var iconField = new ObjectField { objectType = typeof(Sprite), allowSceneObjects = false };
|
||||
iconField.value = iconProp.objectReferenceValue as Sprite;
|
||||
iconField.style.flexGrow = 0.8f;
|
||||
iconField.style.marginLeft = 4;
|
||||
iconField.RegisterValueChangedCallback(evt =>
|
||||
{
|
||||
serializedObject.Update();
|
||||
_entriesProp.GetArrayElementAtIndex(index)
|
||||
.FindPropertyRelative("Icon").objectReferenceValue = evt.newValue;
|
||||
serializedObject.ApplyModifiedProperties();
|
||||
UpdatePreviewInRow(row, evt.newValue as Sprite);
|
||||
RefreshCoverage();
|
||||
});
|
||||
row.Add(iconField);
|
||||
|
||||
// ── 预览缩略图 ────────────────────────────────────────────────
|
||||
var preview = new Image { name = "icon-preview" };
|
||||
preview.style.width = 48;
|
||||
preview.style.height = 48;
|
||||
preview.style.flexShrink = 0;
|
||||
preview.style.marginLeft = 4;
|
||||
preview.style.borderTopLeftRadius = 3;
|
||||
preview.style.borderTopRightRadius = 3;
|
||||
preview.style.borderBottomLeftRadius = 3;
|
||||
preview.style.borderBottomRightRadius = 3;
|
||||
preview.style.backgroundColor = new StyleColor(new Color(0f, 0f, 0f, 0.25f));
|
||||
preview.scaleMode = ScaleMode.ScaleToFit;
|
||||
if (iconProp.objectReferenceValue is Sprite spr)
|
||||
preview.sprite = spr;
|
||||
row.Add(preview);
|
||||
|
||||
// ── 删除按钮 ──────────────────────────────────────────────────
|
||||
var delBtn = new Button(() =>
|
||||
{
|
||||
serializedObject.Update();
|
||||
_entriesProp.DeleteArrayElementAtIndex(index);
|
||||
serializedObject.ApplyModifiedProperties();
|
||||
RebuildRows();
|
||||
}) { text = "✕" };
|
||||
delBtn.style.width = 24;
|
||||
delBtn.style.height = 24;
|
||||
delBtn.style.marginLeft = 4;
|
||||
delBtn.style.flexShrink = 0;
|
||||
delBtn.AddToClassList("action-button--danger");
|
||||
delBtn.tooltip = "删除此条目";
|
||||
row.Add(delBtn);
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
private void BuildAddButton()
|
||||
{
|
||||
var addBtn = new Button(() =>
|
||||
{
|
||||
serializedObject.Update();
|
||||
_entriesProp.InsertArrayElementAtIndex(_entriesProp.arraySize);
|
||||
var newEntry = _entriesProp.GetArrayElementAtIndex(_entriesProp.arraySize - 1);
|
||||
newEntry.FindPropertyRelative("BindingPath").stringValue = string.Empty;
|
||||
newEntry.FindPropertyRelative("Icon").objectReferenceValue = null;
|
||||
serializedObject.ApplyModifiedProperties();
|
||||
RebuildRows();
|
||||
}) { text = "+ 新增条目" };
|
||||
addBtn.style.marginTop = 6;
|
||||
addBtn.style.marginLeft = 4;
|
||||
addBtn.style.marginBottom = 4;
|
||||
addBtn.AddToClassList("wizard-factory-btn");
|
||||
_root.Add(addBtn);
|
||||
}
|
||||
|
||||
// ── 自动填充 ──────────────────────────────────────────────────────────
|
||||
|
||||
private void AutoFillFromActionAsset()
|
||||
{
|
||||
var so = target as InputDeviceIconSetSO;
|
||||
if (so == null) return;
|
||||
|
||||
// 查找项目中的 InputReaderSO,获取 InputActionAsset
|
||||
var readerGuids = AssetDatabase.FindAssets("t:InputReaderSO");
|
||||
InputActionAsset actionAsset = null;
|
||||
foreach (var g in readerGuids)
|
||||
{
|
||||
var path = AssetDatabase.GUIDToAssetPath(g);
|
||||
var reader = AssetDatabase.LoadAssetAtPath<ScriptableObject>(path);
|
||||
if (reader == null) continue;
|
||||
var so2 = new SerializedObject(reader);
|
||||
var prop = so2.FindProperty("_inputActions");
|
||||
if (prop?.objectReferenceValue is InputActionAsset asset)
|
||||
{
|
||||
actionAsset = asset;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (actionAsset == null)
|
||||
{
|
||||
EditorUtility.DisplayDialog("未找到 InputActionAsset",
|
||||
"无法在项目中找到 InputReaderSO 或其引用的 InputActionAsset。\n请手动填写绑定路径。", "确定");
|
||||
return;
|
||||
}
|
||||
|
||||
string scheme = so.DeviceType == InputDeviceType.KeyboardMouse ? "Keyboard&Mouse" : "Gamepad";
|
||||
|
||||
serializedObject.Update();
|
||||
|
||||
// 收集已有路径,避免重复
|
||||
var existingPaths = new HashSet<string>();
|
||||
for (int i = 0; i < _entriesProp.arraySize; i++)
|
||||
{
|
||||
var p = _entriesProp.GetArrayElementAtIndex(i).FindPropertyRelative("BindingPath").stringValue;
|
||||
if (!string.IsNullOrEmpty(p)) existingPaths.Add(p);
|
||||
}
|
||||
|
||||
int added = 0;
|
||||
foreach (var action in actionAsset)
|
||||
{
|
||||
foreach (var binding in action.bindings)
|
||||
{
|
||||
if (binding.isComposite) continue;
|
||||
if (!string.IsNullOrEmpty(scheme)
|
||||
&& !string.IsNullOrEmpty(binding.groups)
|
||||
&& !binding.groups.Contains(scheme, System.StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
var effectivePath = binding.effectivePath;
|
||||
if (string.IsNullOrEmpty(effectivePath)) continue;
|
||||
if (existingPaths.Contains(effectivePath)) continue;
|
||||
|
||||
existingPaths.Add(effectivePath);
|
||||
_entriesProp.InsertArrayElementAtIndex(_entriesProp.arraySize);
|
||||
var newEntry = _entriesProp.GetArrayElementAtIndex(_entriesProp.arraySize - 1);
|
||||
newEntry.FindPropertyRelative("BindingPath").stringValue = effectivePath;
|
||||
newEntry.FindPropertyRelative("Icon").objectReferenceValue = null;
|
||||
added++;
|
||||
}
|
||||
}
|
||||
|
||||
serializedObject.ApplyModifiedProperties();
|
||||
RebuildRows();
|
||||
|
||||
if (added == 0)
|
||||
EditorUtility.DisplayDialog("填充完成", "所有路径已存在,无新增条目。", "确定");
|
||||
else
|
||||
Debug.Log($"[InputIconStudio] 自动填充了 {added} 个绑定路径(Sprite 需手动指定)。");
|
||||
}
|
||||
|
||||
private void RemoveEmptySpriteEntries()
|
||||
{
|
||||
serializedObject.Update();
|
||||
int removed = 0;
|
||||
for (int i = _entriesProp.arraySize - 1; i >= 0; i--)
|
||||
{
|
||||
var icon = _entriesProp.GetArrayElementAtIndex(i).FindPropertyRelative("Icon");
|
||||
if (icon.objectReferenceValue == null)
|
||||
{
|
||||
_entriesProp.DeleteArrayElementAtIndex(i);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
serializedObject.ApplyModifiedProperties();
|
||||
RebuildRows();
|
||||
Debug.Log($"[InputIconStudio] 清理了 {removed} 个空 Sprite 条目。");
|
||||
}
|
||||
|
||||
// ── 工具方法 ──────────────────────────────────────────────────────────
|
||||
|
||||
private void RefreshCoverage()
|
||||
{
|
||||
if (_coverageLabel == null || _entriesProp == null) return;
|
||||
serializedObject.Update();
|
||||
int total = _entriesProp.arraySize;
|
||||
int hasIcon = 0;
|
||||
for (int i = 0; i < total; i++)
|
||||
{
|
||||
if (_entriesProp.GetArrayElementAtIndex(i)
|
||||
.FindPropertyRelative("Icon").objectReferenceValue != null)
|
||||
hasIcon++;
|
||||
}
|
||||
_coverageLabel.text = $"覆盖: {hasIcon}/{total}";
|
||||
bool allOk = total > 0 && hasIcon == total;
|
||||
_coverageLabel.EnableInClassList("status-chip--ok", allOk);
|
||||
_coverageLabel.EnableInClassList("status-chip--missing", !allOk);
|
||||
}
|
||||
|
||||
private static void UpdatePreviewInRow(VisualElement row, Sprite sprite)
|
||||
{
|
||||
var preview = row.Q<Image>("icon-preview");
|
||||
if (preview == null) return;
|
||||
preview.sprite = sprite;
|
||||
}
|
||||
|
||||
private static void AddHeaderCell(VisualElement parent, string text, float? flexGrow = null, float? width = null)
|
||||
{
|
||||
var lbl = new Label(text);
|
||||
lbl.style.fontSize = 10;
|
||||
lbl.style.opacity = 0.6f;
|
||||
lbl.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
if (flexGrow.HasValue) lbl.style.flexGrow = flexGrow.Value;
|
||||
if (width.HasValue) lbl.style.width = width.Value;
|
||||
lbl.style.paddingRight = 4;
|
||||
parent.Add(lbl);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f5106bbd57a6fb242b364657a739a8b6
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
1095
Assets/_Game/Scripts/Editor/Input/InputIconStudioWindow.cs
Normal file
1095
Assets/_Game/Scripts/Editor/Input/InputIconStudioWindow.cs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f6534cbe9abbd954bb1ece4cbb2d7747
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<GameObject> _hpCells = new();
|
||||
private readonly List<GameObject> _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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
119
Assets/_Game/Scripts/UI/HUD/InteractPromptWidget.cs
Normal file
119
Assets/_Game/Scripts/UI/HUD/InteractPromptWidget.cs
Normal file
@@ -0,0 +1,119 @@
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using BaseGames.Core;
|
||||
using BaseGames.Core.Events;
|
||||
|
||||
namespace BaseGames.UI.HUD
|
||||
{
|
||||
/// <summary>
|
||||
/// 交互提示 Widget。
|
||||
///
|
||||
/// 职责:
|
||||
/// • 订阅 InteractPromptEventChannelSO 显示/隐藏提示
|
||||
/// • 显示按键图标(Image)+ 动作文本(TMP_Text)
|
||||
/// • 监听 IInputIconService.OnIconSetChanged,在设备切换或改键后自动刷新图标
|
||||
///
|
||||
/// 布置方式:放在 HUD Canvas 下,引用对应的事件频道 SO 资产。
|
||||
/// 不依赖 HUDController,可独立使用。
|
||||
/// </summary>
|
||||
public sealed class InteractPromptWidget : MonoBehaviour
|
||||
{
|
||||
[Header("UI 引用")]
|
||||
[SerializeField] private Image _keyIcon;
|
||||
[SerializeField] private TMP_Text _labelText;
|
||||
[Tooltip("整个提示根节点,控制显示/隐藏")]
|
||||
[SerializeField] private GameObject _root;
|
||||
|
||||
[Header("Event Channels")]
|
||||
[SerializeField] private InteractPromptEventChannelSO _onShowPrompt;
|
||||
[SerializeField] private VoidEventChannelSO _onHidePrompt;
|
||||
|
||||
// ── 运行时状态 ────────────────────────────────────────────────────────
|
||||
private IInputIconService _iconService;
|
||||
private string _currentActionName;
|
||||
private readonly CompositeDisposable _subs = new();
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
// ServiceLocator 可能在此组件 OnEnable 时尚未注册(执行顺序问题),
|
||||
// 延迟到 ShowPrompt 首次调用时再获取,确保服务可用
|
||||
_onShowPrompt?.Subscribe(ShowPrompt).AddTo(_subs);
|
||||
_onHidePrompt?.Subscribe(HidePrompt).AddTo(_subs);
|
||||
|
||||
HidePrompt();
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
_subs.Clear();
|
||||
UnsubscribeFromIconService();
|
||||
}
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────
|
||||
|
||||
private void ShowPrompt(InteractPromptEvent evt)
|
||||
{
|
||||
_currentActionName = evt.ActionName;
|
||||
|
||||
// 延迟绑定:首次显示时获取服务(确保 ServiceLocator 已初始化)
|
||||
if (_iconService == null)
|
||||
{
|
||||
_iconService = ServiceLocator.GetOrDefault<IInputIconService>();
|
||||
if (_iconService != null)
|
||||
_iconService.OnIconSetChanged += RefreshIcon;
|
||||
}
|
||||
|
||||
if (_labelText != null)
|
||||
_labelText.text = evt.LabelText;
|
||||
|
||||
RefreshIcon();
|
||||
|
||||
if (_root != null)
|
||||
_root.SetActive(true);
|
||||
else
|
||||
gameObject.SetActive(true);
|
||||
}
|
||||
|
||||
private void HidePrompt()
|
||||
{
|
||||
_currentActionName = null;
|
||||
|
||||
if (_root != null)
|
||||
_root.SetActive(false);
|
||||
else
|
||||
gameObject.SetActive(false);
|
||||
}
|
||||
|
||||
// ── Icon Refresh ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>设备切换或改键后刷新图标。由 IInputIconService.OnIconSetChanged 调用。</summary>
|
||||
private void RefreshIcon()
|
||||
{
|
||||
if (_keyIcon == null || string.IsNullOrEmpty(_currentActionName)) return;
|
||||
|
||||
var sprite = _iconService?.GetActionIcon(_currentActionName);
|
||||
if (sprite != null)
|
||||
{
|
||||
_keyIcon.sprite = sprite;
|
||||
_keyIcon.enabled = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
// 找不到图标时隐藏图标格,避免显示错误占位图
|
||||
_keyIcon.enabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void UnsubscribeFromIconService()
|
||||
{
|
||||
if (_iconService != null)
|
||||
{
|
||||
_iconService.OnIconSetChanged -= RefreshIcon;
|
||||
_iconService = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/HUD/InteractPromptWidget.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/HUD/InteractPromptWidget.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 85bdb69d66e546f49b6c89941beda368
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
34
Assets/_Game/Scripts/UI/IInputIconService.cs
Normal file
34
Assets/_Game/Scripts/UI/IInputIconService.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// 按键图标服务接口。
|
||||
/// 根据当前输入设备和玩家实际绑定(含改键),返回对应的按键 Sprite。
|
||||
/// 通过 ServiceLocator 注册/查找,与 UI 层完全解耦。
|
||||
/// </summary>
|
||||
public interface IInputIconService
|
||||
{
|
||||
/// <summary>当前活跃输入设备类型。</summary>
|
||||
InputDeviceType CurrentDevice { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 查询指定 Action(如 "Interact")在当前设备上的按键图标。
|
||||
/// 若找不到图标(资源未配置)返回 null。
|
||||
/// </summary>
|
||||
Sprite GetActionIcon(string actionName);
|
||||
|
||||
/// <summary>
|
||||
/// 查询指定 Action 在当前设备上的有效绑定路径(含改键后的路径)。
|
||||
/// 例如:"<Keyboard>/e"、"<Gamepad>/buttonSouth"。
|
||||
/// </summary>
|
||||
string GetActionEffectivePath(string actionName);
|
||||
|
||||
/// <summary>
|
||||
/// 当设备切换或玩家改键后触发。
|
||||
/// 订阅此事件的 UI 组件应在回调中刷新图标显示。
|
||||
/// </summary>
|
||||
event Action OnIconSetChanged;
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/IInputIconService.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/IInputIconService.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b5c091c06f569c24788467c1d4796e71
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
109
Assets/_Game/Scripts/UI/InputDeviceDetector.cs
Normal file
109
Assets/_Game/Scripts/UI/InputDeviceDetector.cs
Normal file
@@ -0,0 +1,109 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
using UnityEngine.InputSystem.LowLevel;
|
||||
|
||||
namespace BaseGames.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// 设备检测器 —— 监听 InputSystem 的事件流,识别玩家最后使用的输入设备类型,
|
||||
/// 并通过 InputDeviceTypeEventChannelSO 广播给全局。
|
||||
///
|
||||
/// 布置方式:挂在 UIRoot 或常驻 GameObject 上;只需存在一个实例。
|
||||
/// </summary>
|
||||
public sealed class InputDeviceDetector : MonoBehaviour
|
||||
{
|
||||
[Header("Event Channel")]
|
||||
[Tooltip("广播当前设备类型变化")]
|
||||
[SerializeField] private InputDeviceTypeEventChannelSO _onDeviceChanged;
|
||||
|
||||
/// <summary>当前活跃输入设备类型,供轮询使用。</summary>
|
||||
public InputDeviceType CurrentDevice { get; private set; } = InputDeviceType.KeyboardMouse;
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
// 监听所有输入事件:每次有任何 StateEvent/DeltaStateEvent 时触发
|
||||
InputSystem.onEvent += OnInputSystemEvent;
|
||||
// 监听设备连接/断开(热插拔)
|
||||
InputSystem.onDeviceChange += OnDeviceChange;
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
InputSystem.onEvent -= OnInputSystemEvent;
|
||||
InputSystem.onDeviceChange -= OnDeviceChange;
|
||||
}
|
||||
|
||||
// ── Event Handlers ────────────────────────────────────────────────────
|
||||
|
||||
private void OnInputSystemEvent(InputEventPtr eventPtr, InputDevice device)
|
||||
{
|
||||
// 只关心真实输入事件,滤掉内部状态事件
|
||||
if (!eventPtr.IsA<StateEvent>() && !eventPtr.IsA<DeltaStateEvent>()) return;
|
||||
|
||||
var detected = ClassifyDevice(device);
|
||||
if (detected == CurrentDevice) return;
|
||||
|
||||
CurrentDevice = detected;
|
||||
_onDeviceChanged?.Raise(CurrentDevice);
|
||||
}
|
||||
|
||||
private void OnDeviceChange(InputDevice device, InputDeviceChange change)
|
||||
{
|
||||
// 当设备重新连接时重新检测(防止手柄拔插后图标仍显示手柄图标)
|
||||
if (change == InputDeviceChange.Reconnected || change == InputDeviceChange.Added)
|
||||
{
|
||||
// 保持当前 CurrentDevice 不变,等到实际输入事件再切换
|
||||
}
|
||||
}
|
||||
|
||||
// ── Device Classification ─────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 根据 InputDevice 的布局层次识别设备类型。
|
||||
/// Unity InputSystem 的设备层次:
|
||||
/// DualShockGamepad → Gamepad → HID
|
||||
/// XInputController → Gamepad → HID
|
||||
/// SwitchProControllerHID → Gamepad → HID
|
||||
/// Keyboard / Mouse
|
||||
/// </summary>
|
||||
private static InputDeviceType ClassifyDevice(InputDevice device)
|
||||
{
|
||||
if (device is Keyboard or Mouse)
|
||||
return InputDeviceType.KeyboardMouse;
|
||||
|
||||
if (device is Gamepad gamepad)
|
||||
{
|
||||
var desc = gamepad.description;
|
||||
string manufacturer = desc.manufacturer ?? string.Empty;
|
||||
string product = desc.product ?? string.Empty;
|
||||
string interfaceName = desc.interfaceName ?? string.Empty;
|
||||
|
||||
// PlayStation: DualShock 3/4 or DualSense (PS5)
|
||||
if (InputSystem.IsFirstLayoutBasedOnSecond(gamepad.layout, "DualShockGamepad")
|
||||
|| product.Contains("DualShock", System.StringComparison.OrdinalIgnoreCase)
|
||||
|| product.Contains("DualSense", System.StringComparison.OrdinalIgnoreCase)
|
||||
|| manufacturer.Contains("Sony", System.StringComparison.OrdinalIgnoreCase))
|
||||
return InputDeviceType.PlayStationController;
|
||||
|
||||
// Nintendo Switch Pro Controller / Joy-Con
|
||||
if (InputSystem.IsFirstLayoutBasedOnSecond(gamepad.layout, "SwitchProControllerHID")
|
||||
|| product.Contains("Switch", System.StringComparison.OrdinalIgnoreCase)
|
||||
|| product.Contains("Joy-Con", System.StringComparison.OrdinalIgnoreCase)
|
||||
|| manufacturer.Contains("Nintendo", System.StringComparison.OrdinalIgnoreCase))
|
||||
return InputDeviceType.SwitchController;
|
||||
|
||||
// Xbox / XInput (DirectInput 会走 HID 路径,XInput 走 XInputController)
|
||||
if (InputSystem.IsFirstLayoutBasedOnSecond(gamepad.layout, "XInputController")
|
||||
|| product.Contains("Xbox", System.StringComparison.OrdinalIgnoreCase)
|
||||
|| interfaceName.Equals("XInput", System.StringComparison.OrdinalIgnoreCase))
|
||||
return InputDeviceType.XboxController;
|
||||
|
||||
// 未知手柄 → 默认 Xbox 图标集
|
||||
return InputDeviceType.XboxController;
|
||||
}
|
||||
|
||||
// 无法识别 → 键鼠
|
||||
return InputDeviceType.KeyboardMouse;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/InputDeviceDetector.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/InputDeviceDetector.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e2705ff30800d20449273062f56e1989
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -13,18 +13,27 @@ namespace BaseGames.UI
|
||||
[System.Serializable]
|
||||
public struct IconEntry
|
||||
{
|
||||
public string BindingPath; // InputSystem binding path,e.g. "<Keyboard>/space"
|
||||
public Sprite Icon;
|
||||
[Tooltip("InputSystem 绑定路径,如 <Keyboard>/space 或 <Gamepad>/buttonSouth。改键后路径变化,图标集中须包含全部可能按键的映射。")]
|
||||
public string BindingPath;
|
||||
public Sprite Icon;
|
||||
}
|
||||
|
||||
[Tooltip("标识此图标集对应的输入设备类型(仅作编辑器说明,运行时由 InputIconService 选择)")]
|
||||
[SerializeField] private InputDeviceType _deviceType;
|
||||
|
||||
[SerializeField] private IconEntry[] _entries;
|
||||
|
||||
/// <summary>此图标集对应的设备类型。</summary>
|
||||
public InputDeviceType DeviceType => _deviceType;
|
||||
|
||||
/// <summary>根据 binding path 查找对应图标;未找到返回 null。</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,35 +1,31 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using BaseGames.Core;
|
||||
using BaseGames.Core.Events;
|
||||
|
||||
namespace BaseGames.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// 输入设备图标切换器(架构 10_UIModule §12)。
|
||||
/// 订阅 EVT_InputDeviceChanged(BoolEventChannelSO,true = 手柄,false = 键鼠),
|
||||
/// 切换后广播给场景内所有 InputIconImage 组件。
|
||||
/// 通常挂在 UIRoot 或 UIManager 同一 GameObject 上。
|
||||
/// 输入设备图标切换器。
|
||||
/// 订阅 InputDeviceTypeEventChannelSO,在设备切换时通知场景内所有 InputIconImage 刷新。
|
||||
///
|
||||
/// ⚠️ 旧版只支持 KB / 手柄二值切换;新版支持 KeyboardMouse / Xbox / PlayStation / Switch。
|
||||
/// 通常挂在 UIRoot 上,与 InputDeviceDetector 配合使用。
|
||||
/// </summary>
|
||||
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<InputIconImage>(FindObjectsInactive.Include, FindObjectsSortMode.None))
|
||||
img.Refresh();
|
||||
}
|
||||
@@ -38,31 +34,73 @@ namespace BaseGames.UI
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
/// <summary>
|
||||
/// 单个按键图标 Image 组件。
|
||||
/// 记录 bindingPath,由 InputDeviceIconSwitcher 切换时自动刷新。
|
||||
///
|
||||
/// 支持两种查询模式:
|
||||
/// • ByActionName(推荐):填写 ActionName(如 "Interact"),
|
||||
/// 由 IInputIconService 自动解析当前设备 + 改键后的实际绑定路径 → 图标。
|
||||
/// • ByBindingPath(兼容/装饰用):直接填写固定路径(如 "<Keyboard>/space"),
|
||||
/// 适合教程截图等不跟随改键变化的场景。
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(Image))]
|
||||
public class InputIconImage : MonoBehaviour
|
||||
{
|
||||
[Tooltip("InputSystem 绑定路径,如 <Keyboard>/space 或 <Gamepad>/buttonSouth")]
|
||||
public enum LookupMode { ByActionName, ByBindingPath }
|
||||
|
||||
[SerializeField] private LookupMode _mode = LookupMode.ByActionName;
|
||||
|
||||
[Tooltip("Action 名称,如 Interact / Jump / Attack(仅 ByActionName 模式使用)")]
|
||||
[SerializeField] private string _actionName;
|
||||
|
||||
[Tooltip("固定绑定路径,如 <Keyboard>/space(仅 ByBindingPath 模式使用)")]
|
||||
[SerializeField] private string _bindingPath;
|
||||
|
||||
private Image _image;
|
||||
private Image _image;
|
||||
private IInputIconService _iconService;
|
||||
|
||||
private void Awake() => _image = GetComponent<Image>();
|
||||
|
||||
private void Start() => Refresh();
|
||||
private void OnEnable()
|
||||
{
|
||||
_iconService = ServiceLocator.GetOrDefault<IInputIconService>();
|
||||
if (_iconService != null)
|
||||
_iconService.OnIconSetChanged += Refresh;
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (_iconService != null)
|
||||
_iconService.OnIconSetChanged -= Refresh;
|
||||
}
|
||||
|
||||
/// <summary>刷新图标显示。设备切换或改键后由 InputDeviceIconSwitcher / InputIconService 调用。</summary>
|
||||
public void Refresh()
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
14
Assets/_Game/Scripts/UI/InputDeviceType.cs
Normal file
14
Assets/_Game/Scripts/UI/InputDeviceType.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace BaseGames.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// 当前活跃输入设备的分类。
|
||||
/// 用于 InputIconService 选择正确的图标集。
|
||||
/// </summary>
|
||||
public enum InputDeviceType
|
||||
{
|
||||
KeyboardMouse,
|
||||
XboxController,
|
||||
PlayStationController, // 覆盖 PS4 / PS5 DualSense
|
||||
SwitchController // 覆盖 Joy-Con 和 Switch Pro Controller
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/InputDeviceType.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/InputDeviceType.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fd8b0f4a166a4dc488a9bb3760085729
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Assets/_Game/Scripts/UI/InputDeviceTypeEventChannelSO.cs
Normal file
8
Assets/_Game/Scripts/UI/InputDeviceTypeEventChannelSO.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
using UnityEngine;
|
||||
using BaseGames.Core.Events;
|
||||
|
||||
namespace BaseGames.UI
|
||||
{
|
||||
[CreateAssetMenu(menuName = "BaseGames/Events/InputDeviceType")]
|
||||
public class InputDeviceTypeEventChannelSO : BaseEventChannelSO<InputDeviceType> { }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: de9e0076c74db0a4797203dc734a5533
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
134
Assets/_Game/Scripts/UI/InputIconService.cs
Normal file
134
Assets/_Game/Scripts/UI/InputIconService.cs
Normal file
@@ -0,0 +1,134 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
using BaseGames.Core;
|
||||
using BaseGames.Core.Events;
|
||||
using BaseGames.Input;
|
||||
|
||||
namespace BaseGames.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// 按键图标服务实现。
|
||||
///
|
||||
/// 职责:
|
||||
/// 1. 侦听 InputDeviceTypeEventChannelSO,更新当前图标集
|
||||
/// 2. 侦听 InputSystem.onActionChange(BoundControlsChanged),改键后刷新
|
||||
/// 3. 提供 GetActionIcon / GetActionEffectivePath,供 UI 查询
|
||||
/// 4. 在 Awake 注册自身到 ServiceLocator
|
||||
///
|
||||
/// 布置方式:与 InputDeviceDetector 同挂在 UIRoot 上;每场景只需一个实例。
|
||||
/// </summary>
|
||||
public sealed class InputIconService : MonoBehaviour, IInputIconService
|
||||
{
|
||||
[Header("Input")]
|
||||
[SerializeField] private InputReaderSO _inputReader;
|
||||
|
||||
[Header("Icon Sets — 按设备类型配置")]
|
||||
[SerializeField] private InputDeviceIconSetSO _kbMouseSet;
|
||||
[SerializeField] private InputDeviceIconSetSO _xboxSet;
|
||||
[SerializeField] private InputDeviceIconSetSO _playStationSet;
|
||||
[SerializeField] private InputDeviceIconSetSO _switchSet;
|
||||
|
||||
[Header("Event Channels")]
|
||||
[SerializeField] private InputDeviceTypeEventChannelSO _onDeviceChanged;
|
||||
|
||||
// ── IInputIconService ─────────────────────────────────────────────────
|
||||
public InputDeviceType CurrentDevice { get; private set; } = InputDeviceType.KeyboardMouse;
|
||||
public event Action OnIconSetChanged;
|
||||
|
||||
private InputDeviceIconSetSO _activeSet;
|
||||
private readonly CompositeDisposable _subs = new();
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────
|
||||
private void Awake()
|
||||
{
|
||||
ServiceLocator.RegisterIfAbsent<IInputIconService>(this);
|
||||
_activeSet = _kbMouseSet;
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
_onDeviceChanged?.Subscribe(HandleDeviceChanged).AddTo(_subs);
|
||||
// 改键后 InputSystem 会广播 BoundControlsChanged
|
||||
InputSystem.onActionChange += HandleActionChange;
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
_subs.Clear();
|
||||
InputSystem.onActionChange -= HandleActionChange;
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
ServiceLocator.Unregister<IInputIconService>(this);
|
||||
}
|
||||
|
||||
// ── Event Handlers ────────────────────────────────────────────────────
|
||||
|
||||
private void HandleDeviceChanged(InputDeviceType deviceType)
|
||||
{
|
||||
CurrentDevice = deviceType;
|
||||
_activeSet = deviceType switch
|
||||
{
|
||||
InputDeviceType.XboxController => _xboxSet ?? _kbMouseSet,
|
||||
InputDeviceType.PlayStationController => _playStationSet ?? _kbMouseSet,
|
||||
InputDeviceType.SwitchController => _switchSet ?? _kbMouseSet,
|
||||
_ => _kbMouseSet,
|
||||
};
|
||||
OnIconSetChanged?.Invoke();
|
||||
}
|
||||
|
||||
private void HandleActionChange(object obj, InputActionChange change)
|
||||
{
|
||||
if (change == InputActionChange.BoundControlsChanged)
|
||||
OnIconSetChanged?.Invoke();
|
||||
}
|
||||
|
||||
// ── IInputIconService impl ────────────────────────────────────────────
|
||||
|
||||
public Sprite GetActionIcon(string actionName)
|
||||
{
|
||||
var path = GetActionEffectivePath(actionName);
|
||||
if (path == null || _activeSet == null) return null;
|
||||
return _activeSet.GetIcon(path);
|
||||
}
|
||||
|
||||
public string GetActionEffectivePath(string actionName)
|
||||
{
|
||||
if (_inputReader == null) return null;
|
||||
var action = _inputReader.FindAction(actionName);
|
||||
if (action == null) return null;
|
||||
|
||||
// 通过 binding.groups 过滤,只返回匹配当前设备控制方案的绑定路径
|
||||
string schemeFilter = GetControlSchemeForDevice(CurrentDevice);
|
||||
|
||||
foreach (var binding in action.bindings)
|
||||
{
|
||||
// 跳过复合绑定的父条目(无实际路径)
|
||||
if (binding.isComposite) continue;
|
||||
|
||||
// 若 binding.groups 不含当前方案,则跳过(允许空 groups 的绑定匹配所有设备)
|
||||
if (!string.IsNullOrEmpty(schemeFilter)
|
||||
&& !string.IsNullOrEmpty(binding.groups)
|
||||
&& !binding.groups.Contains(schemeFilter, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
// effectivePath 已自动合并 overridePath(改键后的路径)
|
||||
var path = binding.effectivePath;
|
||||
if (!string.IsNullOrEmpty(path)) return path;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>将设备类型映射到 InputActionAsset 中配置的控制方案名称。</summary>
|
||||
private static string GetControlSchemeForDevice(InputDeviceType device) => device switch
|
||||
{
|
||||
InputDeviceType.KeyboardMouse => "Keyboard&Mouse",
|
||||
_ => "Gamepad", // Xbox / PS / Switch 共用 Gamepad 方案
|
||||
};
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/UI/InputIconService.cs.meta
Normal file
11
Assets/_Game/Scripts/UI/InputIconService.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2929014148cfee048a326c8382144a22
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -11,11 +11,11 @@ namespace BaseGames.World
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user