Article

Unity 入门实战 10:输入抽象与多端适配(键鼠/手柄/触控一套逻辑)

路线阶段:Unity 入门实战第 10 章。
本章目标:把输入系统从“设备绑定代码”升级为“意图驱动接口”,为后续 WebGL 和移动端铺路。

学习目标

完成本章后,你应该能做到:

  1. 定义统一输入意图模型(移动、交互、施法、菜单)。
  2. 将键鼠、手柄、触屏输入适配到同一套接口。
  3. 解决 WebGL 焦点丢失、鼠标锁定、按键重复触发问题。
  4. 让战斗与交互逻辑不再依赖具体输入设备。

常见问题

  1. PC 可玩,切 WebGL 后按键响应异常。
  2. 触控方案和键鼠方案各写一套业务逻辑。
  3. UI 打开时仍然吞掉战斗输入或反向串线。
  4. 回放与自动化测试无法复现输入路径。

输入意图模型

public struct InputIntent
{
    public float MoveX;
    public float MoveY;

    public bool Sprint;
    public bool Interact;
    public bool LockTarget;

    public bool CastQ;
    public bool CastW;
    public bool CastE;
    public bool CastR;

    public bool Pause;
    public bool OpenInventory;
}

输入采集接口

public interface IInputProvider
{
    InputIntent Sample();
    bool IsAvailable();
}

键鼠实现

public sealed class KeyboardMouseInputProvider : IInputProvider
{
    public bool IsAvailable()
    {
        return true;
    }

    public InputIntent Sample()
    {
        InputIntent intent;
        intent.MoveX = Input.GetAxisRaw("Horizontal");
        intent.MoveY = Input.GetAxisRaw("Vertical");

        var mag = Mathf.Sqrt(intent.MoveX * intent.MoveX + intent.MoveY * intent.MoveY);
        if (mag > 1f)
        {
            intent.MoveX /= mag;
            intent.MoveY /= mag;
        }

        intent.Sprint = Input.GetKey(KeyCode.LeftShift);
        intent.Interact = Input.GetKeyDown(KeyCode.F);
        intent.LockTarget = Input.GetMouseButtonDown(1);

        intent.CastQ = Input.GetKeyDown(KeyCode.Q);
        intent.CastW = Input.GetKeyDown(KeyCode.W);
        intent.CastE = Input.GetKeyDown(KeyCode.E);
        intent.CastR = Input.GetKeyDown(KeyCode.R);

        intent.Pause = Input.GetKeyDown(KeyCode.Escape);
        intent.OpenInventory = Input.GetKeyDown(KeyCode.B);

        return intent;
    }
}

触控实现(虚拟摇杆)

public interface IVirtualJoystick
{
    Vector2 Value { get; }
}

public interface IVirtualButton
{
    bool DownThisFrame { get; }
    bool Pressing { get; }
}

public sealed class TouchInputProvider : IInputProvider
{
    private readonly IVirtualJoystick _move;
    private readonly IVirtualButton _sprint;
    private readonly IVirtualButton _interact;
    private readonly IVirtualButton _lock;
    private readonly IVirtualButton _q;
    private readonly IVirtualButton _w;
    private readonly IVirtualButton _e;
    private readonly IVirtualButton _r;
    private readonly IVirtualButton _pause;
    private readonly IVirtualButton _bag;

    public TouchInputProvider(
        IVirtualJoystick move,
        IVirtualButton sprint,
        IVirtualButton interact,
        IVirtualButton lockTarget,
        IVirtualButton q,
        IVirtualButton w,
        IVirtualButton e,
        IVirtualButton r,
        IVirtualButton pause,
        IVirtualButton bag)
    {
        _move = move;
        _sprint = sprint;
        _interact = interact;
        _lock = lockTarget;
        _q = q;
        _w = w;
        _e = e;
        _r = r;
        _pause = pause;
        _bag = bag;
    }

    public bool IsAvailable()
    {
        return Input.touchSupported;
    }

    public InputIntent Sample()
    {
        var axis = _move.Value;

        InputIntent intent;
        intent.MoveX = axis.x;
        intent.MoveY = axis.y;

        intent.Sprint = _sprint.Pressing;
        intent.Interact = _interact.DownThisFrame;
        intent.LockTarget = _lock.DownThisFrame;

        intent.CastQ = _q.DownThisFrame;
        intent.CastW = _w.DownThisFrame;
        intent.CastE = _e.DownThisFrame;
        intent.CastR = _r.DownThisFrame;

        intent.Pause = _pause.DownThisFrame;
        intent.OpenInventory = _bag.DownThisFrame;

        return intent;
    }
}

输入路由器

public sealed class InputRouter : IUpdatable
{
    public int Order { get { return 100; } }

    private readonly List<IInputProvider> _providers;
    private readonly EventBus _eventBus;
    private InputIntent _latest;

    public InputRouter(List<IInputProvider> providers, EventBus eventBus)
    {
        _providers = providers;
        _eventBus = eventBus;
    }

    public InputIntent LatestIntent
    {
        get { return _latest; }
    }

    public void Tick(float dt, float unscaledDt)
    {
        for (var i = 0; i < _providers.Count; i++)
        {
            var p = _providers[i];
            if (!p.IsAvailable())
            {
                continue;
            }

            _latest = p.Sample();
            _eventBus.Publish("InputIntentUpdated", _latest);
            return;
        }

        _latest = default(InputIntent);
    }
}

WebGL 专项处理

1. 焦点状态

public sealed class WebGlFocusGuard : MonoBehaviour
{
    public bool HasFocus { get; private set; }

    private void OnApplicationFocus(bool focus)
    {
        HasFocus = focus;
        FoundationLog.Info("Input", "focus=" + focus);
    }

    private void OnApplicationPause(bool pause)
    {
        HasFocus = !pause;
    }
}

HasFocus=false 时,输入路由器可直接输出空意图,避免后台误触。

2. 鼠标锁定策略

public sealed class CursorLockService
{
    public void SetBattleMode(bool active)
    {
        if (active)
        {
            Cursor.lockState = CursorLockMode.Locked;
            Cursor.visible = false;
        }
        else
        {
            Cursor.lockState = CursorLockMode.None;
            Cursor.visible = true;
        }
    }
}

与战斗系统对接

  1. PlayerController 读取 LatestIntent.MoveX/MoveY
  2. SkillCastController 读取 CastQ/W/E/RLockTarget
  3. InteractionController 读取 Interact
  4. UIRootManager 读取 Pause/OpenInventory

所有业务只读 InputIntent,不再直接读 Input 静态 API。

自动化与回放收益

输入抽象后可直接构造伪输入:

public sealed class ReplayInputProvider : IInputProvider
{
    private readonly Queue<InputIntent> _queue;

    public ReplayInputProvider(Queue<InputIntent> queue)
    {
        _queue = queue;
    }

    public bool IsAvailable() { return true; }

    public InputIntent Sample()
    {
        if (_queue.Count == 0) return default(InputIntent);
        return _queue.Dequeue();
    }
}

验收清单

  1. 键鼠、触控都能控制同一套角色与战斗逻辑。
  2. WebGL 切换焦点后恢复输入正常,不出现粘键。
  3. UI 打开时战斗输入可按策略屏蔽。
  4. 回放输入可稳定复现操作序列。

常见坑

坑 1:InputIntent 混入设备细节

例如包含 MouseDeltaX 直接喂业务,会破坏抽象边界。

坑 2:多个输入源同时生效

需明确优先级,避免键鼠与触控冲突。

坑 3:UI 层直接消费原始 Input

会绕过输入路由,造成行为不一致。

本月作业

实现“输入录制回放联动”:

  1. 每帧记录 InputIntent 到回放数据。
  2. 支持用 ReplayInputProvider 驱动完整战斗。
  3. 对比实时输入与回放输入导致的关键指标差异。

下一章进入 Unity 入门实战 11:关卡流程与状态机(菜单、加载、战斗、结算一体化切换)。