chore: initial commit

This commit is contained in:
2026-05-08 11:04:00 +08:00
commit f55d2a57c3
6278 changed files with 866081 additions and 0 deletions

View File

@@ -0,0 +1,503 @@
# 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);
```