# 04 · 输入模块 > **命名空间** `BaseGames.Input` > **程序集** `BaseGames.Input` > **路径** `Assets/Scripts/Input/` > **依赖** `BaseGames.Core.Events`、`Unity.InputSystem` --- ## 目录 1. [InputReaderSO](#1-inputreaderso) 2. [InputReaderSO 字段与事件完整列表](#2-inputreaderso-字段与事件完整列表) 3. [Input Actions 资产结构](#3-input-actions-资产结构) 4. [InputBuffer — 输入缓冲](#4-inputbuffer--输入缓冲) 5. [Action Map 切换规则](#5-action-map-切换规则) 6. [按键重绑定接口](#6-按键重绑定接口) 7. [Persistent 场景挂载](#7-persistent-场景挂载) --- ## 1. InputReaderSO ``` 路径: Assets/Scripts/Input/InputReaderSO.cs 资产: Assets/Data/Player/PLY_InputReader.asset(全局单例 SO) 程序集: BaseGames.Input ``` `InputReaderSO` 是输入系统的**唯一门面**。内部持有生成的 `PlayerInputActions` C# 类,向外暴露纯 C# `event Action` 委托,各游戏系统通过订阅这些事件获取输入,**不直接持有 `PlayerInput` 组件引用**。 ```csharp [CreateAssetMenu(menuName = "Input/InputReader")] public class InputReaderSO : ScriptableObject, IInputActionCollection2 { // 内部持有生成的 Input Actions private PlayerInputActions _actions; // EventChannel 引用(SO→SO 注入,供无法直接订阅 C# 事件的全局系统使用) [SerializeField] private VoidEventChannelSO _onPauseRequested; // → EVT_PauseRequested(GameManager 订阅) // 在首次访问时懒初始化(OnEnable 兜底) private void OnEnable() => _actions ??= new PlayerInputActions(); } ``` --- ## 2. InputReaderSO 字段与事件完整列表 ### 移动 ```csharp public event Action MoveEvent; // 持续:方向向量 public Vector2 MoveInput { get; } // 当前帧值(Polling 用) ``` ### 跳跃 ```csharp public event Action JumpStartedEvent; // 按下(触发跳跃) public event Action JumpCancelledEvent; // 松开(可变跳跃高度 CutJump) ``` ### 攻击 ```csharp public event Action AttackEvent; // 普通攻击按下 public event Action DownAttackEvent; // 下劈(按住下+攻击,或独立键位) public event Action UpAttackEvent; // 上劈(按住上+攻击,或独立键位) ``` ### 弹反 ```csharp public event Action ParryEvent; // 弹反按下 ``` ### 冲刺 ```csharp public event Action DashEvent; // 冲刺按下 ``` ### 灵泉 ```csharp public event Action UseSpringEvent; // 使用灵泉(消耗 SpringCharges) ``` ### 形态切换 ```csharp public event Action SwitchSkyFormEvent; // 天魂形态 public event Action SwitchEarthFormEvent; // 地魂形态 public event Action SwitchDeathFormEvent; // 命魂形态 ``` ### 技能 ```csharp public event Action SoulSkillEvent; // 当前形态魂技能(消耗灵力) public event Action SpiritSkill1StartedEvent; // 魄技能 1 按下 public event Action SpiritSkill1CancelledEvent; // 魄技能 1 松开(蓄力型) public event Action SpiritSkill2StartedEvent; // 魄技能 2 按下 public event Action SpiritSkill2CancelledEvent; // 魄技能 2 松开(蓄力型) ``` ### 交互 ```csharp public event Action InteractEvent; // 与 NPC/物件交互 ``` ### UI ```csharp public event Action PauseEvent; // 暂停键:同时 Raise _onPauseRequested(→ EVT_PauseRequested) public event Action NavigateEvent; // UI 导航 public event Action SubmitEvent; // 确认 public event Action CancelEvent; // 取消 public event Action PointEvent; // 鼠标/触摸位置(UI Action Map Point) ``` ### Action Map 切换 ```csharp public void EnableGameplayInput(); // 启用 Gameplay Map,禁用 UI Map public void EnableUIInput(); // 启用 UI Map,禁用 Gameplay Map public void DisableAllInput(); // 全部禁用(过场/加载中) ``` --- ## 3. Input Actions 资产结构 ``` 资产路径: Assets/Settings/PlayerInputActions.inputactions ``` ### Gameplay Action Map | Action 名 | 类型 | 绑定(键盘/手柄) | |-----------|------|----------------| | `Move` | Value Vector2 | WASD / 左摇杆 | | `Jump` | Button | Space / 南键(×/A) | | `Attack` | Button | J / 西键(□/X) | | `Parry` | Button | K / 北键(△/Y) | | `Dash` | Button | L / 东键(○/B) | | `UseSpring` | Button | H / R1 | | `SwitchSkyForm` | Button | 1 / D-Pad Up | | `SwitchEarthForm` | Button | 2 / D-Pad Down | | `SwitchDeathForm` | Button | 3 / D-Pad Right | | `SoulSkill` | Button | U / L2 | | `SpiritSkill1` | Button | I / R2(带 hold 取消事件)| | `SpiritSkill2` | Button | O / L1(带 hold 取消事件)| | `Interact` | Button | F / D-Pad Left | | `Pause` | Button | Esc / Start | ### UI Action Map | Action 名 | Value 类型 | 绑定 | |-----------|-----------|------| | `Navigate` | Vector2 | 方向键 / 左摇杆 | | `Submit` | Button | Enter / 南键 | | `Cancel` | Button | Esc / 东键 | | `Pause` | Button | Esc / Start | | `Point` | Value (Vector2) | 鼠标/触摸位置(UI 点击坐标) | --- ## 4. InputBuffer — 输入缓冲 `InputBuffer` 是挂在 `PlayerController` 同一 GameObject 上的组件,不独立存在。 ```csharp // 路径: Assets/Scripts/Input/InputBuffer.cs public class InputBuffer : MonoBehaviour { // 各动作独立缓冲时长(与 Design/01 §4 缓冲时长配置表一致) [SerializeField] private float _jumpBufferDuration = 0.15f; // 跳跃:宽容窗口 [SerializeField] private float _attackBufferDuration = 0.12f; // 攻击:接续连段 [SerializeField] private float _dashBufferDuration = 0.10f; // 冲刺:小量容错 // 弹反 / UseSpring / SoulSkill / SpiritSkill 缓冲时长为 0s(不缓冲) [SerializeField] private InputReaderSO _inputReader; // 各动作的缓冲计时器 private float _jumpBuffer; private float _attackBuffer; private float _dashBuffer; private void Update() { // 每帧倒计时 _jumpBuffer = Mathf.Max(0, _jumpBuffer - Time.deltaTime); _attackBuffer = Mathf.Max(0, _attackBuffer - Time.deltaTime); _dashBuffer = Mathf.Max(0, _dashBuffer - Time.deltaTime); } // 检测(各 State 在 OnStateEnter 调用) public bool ConsumeJump() { if (_jumpBuffer > 0) { _jumpBuffer = 0; return true; } return false; } public bool ConsumeAttack() { if (_attackBuffer > 0) { _attackBuffer = 0; return true; } return false; } public bool ConsumeDash() { if (_dashBuffer > 0) { _dashBuffer = 0; return true; } return false; } // 写入(订阅 InputReaderSO 事件) private void OnEnable() { _inputReader.JumpStartedEvent += () => _jumpBuffer = _jumpBufferDuration; _inputReader.AttackEvent += () => _attackBuffer = _attackBufferDuration; _inputReader.DashEvent += () => _dashBuffer = _dashBufferDuration; } private void OnDisable() { _inputReader.JumpStartedEvent -= () => _jumpBuffer = _jumpBufferDuration; _inputReader.AttackEvent -= () => _attackBuffer = _attackBufferDuration; _inputReader.DashEvent -= () => _dashBuffer = _dashBufferDuration; } } ``` **Coyote Time** 实现位于 `PlayerMovement`(不在此模块),见[05_PlayerModule.md](./05_PlayerModule.md)。 --- ## 5. Action Map 切换规则 | 游戏状态(GameState) | 输入 Map | 调用方 | |----------------------|---------|--------| | `Initializing` / `LoadingScene` | `DisableAllInput()` | `GameManager`(订阅 EVT_GameStateChanged) | | `MainMenu` | `EnableUIInput()` | `GameManager` | | `Gameplay` / `BossFight` | `EnableGameplayInput()` | `GameManager` | | `Paused` | `EnableUIInput()` | `GameManager` | | `Dead` | `DisableAllInput()`,然后 DeathScreen 出现后 `EnableUIInput()` | `GameManager` | | `Cutscene` | `DisableAllInput()` | `GameManager` / `CutsceneManager` | | 对话(Dialogue)中 | `EnableUIInput()` | `DialogueManager`(对话开始/结束时调用)| --- ## 6. 按键重绑定接口 ```csharp // InputReaderSO 提供的重绑定 API(供 SettingsPanel 调用) public class InputReaderSO : ScriptableObject { // 开始交互式重绑定(返回 RebindOperation,调用方负责 Dispose) public InputActionRebindingExtensions.RebindingOperation StartRebinding(string actionName, int bindingIndex, Action onComplete, Action onCancel); // 保存当前绑定覆盖(JSON)→ PlayerPrefs public void SaveBindingOverrides(); // 从 PlayerPrefs 加载并应用绑定覆盖 public void LoadBindingOverrides(); // 重置为默认绑定 public void ResetBindings(); } ``` ### RebindPanel — 完整重绑定 UI ```csharp // 路径: Assets/Scripts/UI/Settings/RebindPanel.cs // 设置界面中的完整按键重绑定面板 // 每个可绑定 Action 对应一行 RebindActionRow public class RebindPanel : MonoBehaviour { [SerializeField] private InputReaderSO _inputReader; [SerializeField] private RebindActionRow[] _rows; // Inspector 配置,顺序对应 HUD 布局 [SerializeField] private Button _resetAllButton; [SerializeField] private ConflictDetector _conflictDetector; private void Awake() { _resetAllButton.onClick.AddListener(OnResetAll); foreach (var row in _rows) row.Initialize(_inputReader, _conflictDetector, OnRebindRequested); } private void OnRebindRequested(RebindActionRow row) { // 禁用其他行的交互,防止同时启动多个重绑定操作 foreach (var r in _rows) r.SetInteractable(r == row); row.StartRebind(onFinished: () => { foreach (var r in _rows) r.SetInteractable(true); _inputReader.SaveBindingOverrides(); }); } private void OnResetAll() { _inputReader.ResetBindings(); _inputReader.SaveBindingOverrides(); foreach (var row in _rows) row.RefreshDisplay(); } } // 路径: Assets/Scripts/UI/Settings/RebindActionRow.cs // 单行:Action 名 + 当前绑定显示 + 点击启动重绑定 public class RebindActionRow : MonoBehaviour { [SerializeField] private string _actionName; // Input Action 名称 [SerializeField] private int _bindingIndex; // 0 = 主绑定,1 = 副绑定 [SerializeField] private TMP_Text _actionLabel; [SerializeField] private Button _bindButton; [SerializeField] private TMP_Text _currentBindingText; private InputReaderSO _inputReader; private ConflictDetector _conflictDetector; private Action _onRebindRequested; public void Initialize( InputReaderSO reader, ConflictDetector detector, Action onRequest) { _inputReader = reader; _conflictDetector = detector; _onRebindRequested = onRequest; _bindButton.onClick.AddListener(() => _onRebindRequested?.Invoke(this)); RefreshDisplay(); } public void StartRebind(Action onFinished) { _currentBindingText.text = "按下新按键…"; _inputReader.StartRebinding(_actionName, _bindingIndex, onComplete: () => { RefreshDisplay(); CheckConflicts(); onFinished?.Invoke(); }, onCancel: () => { RefreshDisplay(); onFinished?.Invoke(); }); } public void RefreshDisplay() { var action = _inputReader.FindAction(_actionName); _currentBindingText.text = action != null ? InputControlPath.ToHumanReadableString( action.bindings[_bindingIndex].effectivePath, InputControlPath.HumanReadableStringOptions.OmitDevice) : "—"; } public void SetInteractable(bool interactable) => _bindButton.interactable = interactable; private void CheckConflicts() { var conflicts = _conflictDetector.FindConflicts(_inputReader.GetAllActionMap()); // 高亮冲突行(通过 ConflictDetector 回调) foreach (var row in FindObjectsOfType()) row.SetConflictHighlight(conflicts.Contains(row._actionName)); } public void SetConflictHighlight(bool conflict) => _currentBindingText.color = conflict ? Color.red : Color.white; } ``` ### ConflictDetector — 冲突检测 ```csharp // 路径: Assets/Scripts/Input/ConflictDetector.cs // 检测两个 Action 是否绑定了相同的按键路径 public class ConflictDetector : MonoBehaviour { // 返回存在冲突的 Action 名称集合 public HashSet FindConflicts(IEnumerable actions) { var pathToActions = new Dictionary>(); foreach (var action in actions) { foreach (var binding in action.bindings) { if (binding.isComposite || string.IsNullOrEmpty(binding.effectivePath)) continue; if (!pathToActions.ContainsKey(binding.effectivePath)) pathToActions[binding.effectivePath] = new List(); pathToActions[binding.effectivePath].Add(action.name); } } var conflicted = new HashSet(); foreach (var kv in pathToActions) if (kv.Value.Count > 1) foreach (var name in kv.Value) conflicted.Add(name); return conflicted; } } ``` ### RebindPersistence — 持久化绑定 ```csharp // InputReaderSO 内部的持久化实现(扩展上方 SaveBindingOverrides/LoadBindingOverrides) // 序列化格式: PlayerPrefs key = "InputBindings", value = JSON 覆盖字符串 public partial class InputReaderSO : ScriptableObject { private const string PrefKey = "InputBindings"; public void SaveBindingOverrides() { string json = _actions.asset.SaveBindingOverridesAsJson(); PlayerPrefs.SetString(PrefKey, json); PlayerPrefs.Save(); } public void LoadBindingOverrides() { if (PlayerPrefs.HasKey(PrefKey)) { string json = PlayerPrefs.GetString(PrefKey); _actions.asset.LoadBindingOverridesFromJson(json); } } public void ResetBindings() { _actions.asset.RemoveAllBindingOverrides(); PlayerPrefs.DeleteKey(PrefKey); } // 辅助:获取当前 Gameplay map 所有 InputAction(供 ConflictDetector 使用) public IEnumerable GetAllActionMap() => _actions.asset.FindActionMap("Gameplay").actions; } ``` --- ## 7. Persistent 场景挂载 `InputReaderSO` 本身是 ScriptableObject(Asset),不需要 GameObject 挂载。 但 Unity InputSystem 的 `PlayerInput` 组件可选(InputReaderSO 直接 `new PlayerInputActions()` 即可,无需 `PlayerInput` 组件)。 ``` Scene: Persistent └── InputReader (GameObject) └── InputReaderInitializer.cs ├── [SerializeField] InputReaderSO _inputReader └── Awake(): _inputReader.Initialize(); // 创建 PlayerInputActions 实例 _inputReader.LoadBindingOverrides(); // 加载用户重映射 _inputReader.EnableGameplayInput(); // 默认游戏play输入 ``` --- ## 8. KeyDisplayNameResolver — 按键名本地化 `KeyDisplayNameResolver` 将 InputSystem 返回的英文按键路径名转换为本地化显示字符串,供 `RebindActionRow` 渲染到 UI。 ```csharp // 路径: Assets/Scripts/Input/KeyDisplayNameResolver.cs public static class KeyDisplayNameResolver { // 中文覆盖字典(键 = InputControlPath 的 Human-Readable 片段,值 = 中文显示名) private static readonly Dictionary _overrides = new Dictionary { { "Space", "空格" }, { "Enter", "回车" }, { "Backspace", "退格" }, { "Escape", "退出" }, { "Left Arrow","←" }, { "Right Arrow","→" }, { "Up Arrow", "↑" }, { "Down Arrow","↓" }, { "Left Shift","左 Shift" }, { "Right Shift","右 Shift" }, { "Left Ctrl", "左 Ctrl" }, { "Right Ctrl","右 Ctrl" }, { "Left Alt", "左 Alt" }, { "Right Alt", "右 Alt" }, { "Tab", "Tab" }, { "Caps Lock", "大写锁定" }, }; /// /// 将 effectivePath 转换为本地化显示字符串。 /// 中文覆盖优先;未找到覆盖时回落到 InputControlPath.ToHumanReadableString。 /// public static string Resolve(string effectivePath) { if (string.IsNullOrEmpty(effectivePath)) return "—"; string human = InputControlPath.ToHumanReadableString( effectivePath, InputControlPath.HumanReadableStringOptions.OmitDevice); return _overrides.TryGetValue(human, out string localized) ? localized : human; } } ``` `RebindActionRow.RefreshDisplay()` 改为调用 `KeyDisplayNameResolver.Resolve()` 替代直接的 `ToHumanReadableString`: ```csharp _currentBindingText.text = KeyDisplayNameResolver.Resolve( action.bindings[_bindingIndex].effectivePath); ```