17 KiB
17 KiB
04 · 输入模块
命名空间
BaseGames.Input
程序集BaseGames.Input
路径Assets/Scripts/Input/
依赖BaseGames.Core.Events、Unity.InputSystem
目录
- InputReaderSO
- InputReaderSO 字段与事件完整列表
- Input Actions 资产结构
- InputBuffer — 输入缓冲
- Action Map 切换规则
- 按键重绑定接口
- Persistent 场景挂载
1. InputReaderSO
路径: Assets/Scripts/Input/InputReaderSO.cs
资产: Assets/Data/Player/PLY_InputReader.asset(全局单例 SO)
程序集: BaseGames.Input
InputReaderSO 是输入系统的唯一门面。内部持有生成的 PlayerInputActions C# 类,向外暴露纯 C# event Action 委托,各游戏系统通过订阅这些事件获取输入,不直接持有 PlayerInput 组件引用。
[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 字段与事件完整列表
移动
public event Action<Vector2> MoveEvent; // 持续:方向向量
public Vector2 MoveInput { get; } // 当前帧值(Polling 用)
跳跃
public event Action JumpStartedEvent; // 按下(触发跳跃)
public event Action JumpCancelledEvent; // 松开(可变跳跃高度 CutJump)
攻击
public event Action AttackEvent; // 普通攻击按下
public event Action DownAttackEvent; // 下劈(按住下+攻击,或独立键位)
public event Action UpAttackEvent; // 上劈(按住上+攻击,或独立键位)
弹反
public event Action ParryEvent; // 弹反按下
冲刺
public event Action DashEvent; // 冲刺按下
灵泉
public event Action UseSpringEvent; // 使用灵泉(消耗 SpringCharges)
形态切换
public event Action SwitchSkyFormEvent; // 天魂形态
public event Action SwitchEarthFormEvent; // 地魂形态
public event Action SwitchDeathFormEvent; // 命魂形态
技能
public event Action SoulSkillEvent; // 当前形态魂技能(消耗灵力)
public event Action SpiritSkill1StartedEvent; // 魄技能 1 按下
public event Action SpiritSkill1CancelledEvent; // 魄技能 1 松开(蓄力型)
public event Action SpiritSkill2StartedEvent; // 魄技能 2 按下
public event Action SpiritSkill2CancelledEvent; // 魄技能 2 松开(蓄力型)
交互
public event Action InteractEvent; // 与 NPC/物件交互
UI
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 切换
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 上的组件,不独立存在。
// 路径: 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。
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. 按键重绑定接口
// 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
// 路径: 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 — 冲突检测
// 路径: 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 — 持久化绑定
// 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 本身是 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。
// 路径: 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:
_currentBindingText.text = KeyDisplayNameResolver.Resolve(
action.bindings[_bindingIndex].effectivePath);