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

17 KiB
Raw Permalink Blame History

04 · 输入模块

命名空间 BaseGames.Input
程序集 BaseGames.Input
路径 Assets/Scripts/Input/
依赖 BaseGames.Core.EventsUnity.InputSystem


目录

  1. InputReaderSO
  2. InputReaderSO 字段与事件完整列表
  3. Input Actions 资产结构
  4. InputBuffer — 输入缓冲
  5. Action Map 切换规则
  6. 按键重绑定接口
  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 组件引用

[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 字段与事件完整列表

移动

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 本身是 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。

// 路径: 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);