Files
zeling_v2/Docs/Architecture/04_InputModule.md
2026-05-08 11:04:00 +08:00

504 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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_PauseRequestedGameManager 订阅)
// 在首次访问时懒初始化OnEnable 兜底)
private void OnEnable() => _actions ??= new PlayerInputActions();
}
```
---
## 2. InputReaderSO 字段与事件完整列表
### 移动
```csharp
public event Action<Vector2> 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<Vector2> NavigateEvent; // UI 导航
public event Action SubmitEvent; // 确认
public event Action CancelEvent; // 取消
public event Action<Vector2> 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<RebindActionRow> _onRebindRequested;
public void Initialize(
InputReaderSO reader,
ConflictDetector detector,
Action<RebindActionRow> 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<RebindActionRow>())
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<string> FindConflicts(IEnumerable<InputAction> actions)
{
var pathToActions = new Dictionary<string, List<string>>();
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<string>();
pathToActions[binding.effectivePath].Add(action.name);
}
}
var conflicted = new HashSet<string>();
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<InputAction> GetAllActionMap()
=> _actions.asset.FindActionMap("Gameplay").actions;
}
```
---
## 7. Persistent 场景挂载
`InputReaderSO` 本身是 ScriptableObjectAsset不需要 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<string, string> _overrides = new Dictionary<string, string>
{
{ "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", "大写锁定" },
};
/// <summary>
/// 将 effectivePath 转换为本地化显示字符串。
/// 中文覆盖优先;未找到覆盖时回落到 InputControlPath.ToHumanReadableString。
/// </summary>
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);
```