Ylisar 发表于 2022-1-20 16:55

Unity原始输入系统封装

1.简介

这篇文章会讲述如何在Unity的原始输入系统基础之上封装一个更加健壮且可扩展的输入系统,这里的Unity的原始输入是指UnityEngine.Input,针对Input的封装目的有点类似于Unity新的输入系统InputSystem,我自己的项目中没有用到InputSystem,主要原因是我认为Input已经够用了,后续如果有需要的话,我会再写一篇针对InputSystem的文章。
那么对于已经开始使用InputSystem的人来说,这篇文章可以仅当一种思路拓展和参考。因为我们如果要知道InputSystem比Input好在哪,这样可以更好的做出选择。
2.封装原始输入的诱因

我们先看一段简单的代码,然后大概的猜测它的问题。
using UnityEngine;

public class Player: MonoBehaviour{
    void Update(){
      
      // 检测输入
      if(Input.GetMouseButtonDown(0)){
            Fire();                // 开火
      }else if(Input.GetKeyDown(KeyCode.Space)){
            Jump();                // 跳跃
      }else if(Input.GetAxis("Horizontal") != 0){
            Move();                // 移动
      }
    }
}
上述代码可能经常在测试阶段的时候用到,这样的代码不能用到实战中,它主要有三个问题。

[*]功能和按键绑死
这个问题很好理解,大部分的游戏都应该支持我们对鼠标键盘进行改键,这样编写代码的方式并不支持对改键。
[*]需要额外的条件来阻断输入
这个问题可能并不是所有的游戏都会有,在某些游戏中,也许玩家需要跟某些Npc交易,买卖武器装备等。那么当我们打开商店的时候,玩家的操作此时应该是被阻断的,也就是玩家此时不可操作,除非关闭商店。上述代码要想在打开商店时阻断玩家输入,需要额外增加一个参数。所以我们理所当然的希望控制器内部有一个参数用来阻断所有的输入。
[*]控制效果没有通用性
现在很多独立游戏都会针对多个平台分发,同时支持在PC或者主机体验,这就要求我们的控制器同时支持多个类型的控制器。并且还可以根据控制方式的不同而切换控制策略。比如对于键盘鼠标来说,有很多操作可以直接通过鼠标来实现,但是手柄操作就会劣势很多。这里也是一个很大的区别,于是我们必须该点做出改善。
3.代码review

这部分的代码我已经封装完成了,目前能够满足我个人的开发需要。这里仅作一部分的review,当然是从0开始构建,应该不会过于复杂。我会同时写出我的思考与设计思路。
3.1 关于映射策略

既然按键和功能绑定了,我们就需要解绑,说的直白一点就是,我们希望按键是一个变量。所有的按键都是通过KeyCode来记录的,所以任何一个按键输入,都应该保存一个KeyCode值,这样只要我们重设这个变量,就可以实现按键的切换了。
public class KeyboardKey{
    private KeyCode key;
    public KeyboardKey(KeyCode k){
      this.key = k;
    }
    public void resetKey(KeyCode k){
      this.key = k;
    }
}
上述的类只是从让按键成为一个可以替换的变量的想法而编写的,而且由于Input是一个静态类,所以我们可以直接通过这个类来封装三种不同的检测。
public class KeyboardKey{
    private KeyCode key;
    public bool isDown{
      get => Input.GetKeyDown(key);
    }
    public bool isUp{
      get => Input.GetKeyUp(key);
    }
    public bool isPushing{
      get => Input.GetKeyDown(key);
    }
    public KeyboardKey(KeyCode k){
      this.key = k;
    }
    public void resetKey(KeyCode k){
      this.key = k;
    }
}
但是单纯的将KeyCode封装起来并不能产生映射的作用,这只是一个基础而已。也就是我们还需要先将玩家所有的操作或者其他什么UI的操作先写成枚举类型,然后将这些枚举值映射成KeyboardKey
public enum PlayerOperations{
        FIRE,
        JUMP,
        //...
}
public class ControllerBase<T> where T: System.Enum{
    /* 将所有的映射都写到字典中,以方便改键 */
    private Dictionary<T, KeyboardKey> keyMaps;
}
上述的PlayerOperations就是玩家所有可以进行的操作,可以不断地补充的操作,那么我们将这些作为键值填充到ControllerBase<PlayerOperations>的字典中去,这样就产生了从PlayerOperations到KeyboardKey的映射,此后我们检测按键的输入时,就只需要输入一个PlayerOperations值即可,当我们要改键时,输入PlayerOperations,然后查询到对应的KeyboardKey,改变这个KeyboardKey的KeyCode值即可。
这样看起来还不错,不过还忽略了一个非常重要的因素。并不是所有的按键都是键盘,还有的输入是一个程度值,比如Input.GetAxis("Horizontal")就是一个-1到1的值,以及鼠标也有按键,轮滑,鼠标位置等输入。这些统统都要用同一个枚举类型来检测的话,我们就需要准备多个字典来存储了,当检测不同类型的按键时,就到不同类型的字典中去查询。
这样,我们仍然用的是一个统一的控制器,只是检测的内容发生了改变。所以先增加多个对不同按键的封装,这里我们要注意的是,并不是说,针对鼠标和键盘的操作我们一定要区分开,虽然它们检测的按键不同,一个是KeyCode类型的值,一个是int类型的值,检测用的函数也不一样,一个是GetKeyDown,一个是GetMouseButtonDown,但其实非常的类似。所以这两个操作是可以合并为同一个类型的。统称为PushKey
我们可以先定义PushKey基类,并据此来实现KeyboardKey和MouseKey
public abstract class PushKey{
    public abstract bool isDown{get;}
    public abstract bool isUp{get;}
    public abstract bool isPushing{get;}
}
public class KeyboardKey: PushKey{
    private KeyCode key;
    public override bool isDown{get => Input.GetKeyDown(key); }
    public override bool isUp{get => Input.GetKeyUp(key); }
    public override bool isPushing{get => Input.GetKey(key); }
    public KeyboardKey(KeyCode key){
      this.key = key;
    }
    public void resetKey(KeyCode key){
      this.key = key;
    }
}
public class MouseKey: PushKey{
    private int key;
    public override bool isDown{get => Input.GetMouseButtonDown(key); }
    public override bool isUp{get => Input.GetMouseButtonUp(key); }
        public override bool isPushing{get => Input.GetMouseButton(key); }
    public MouseKey(int key){
      this.key = key;
    }
    public void resetKey(int key){
      this.key = key;
    }
}
那么此时PushKey就涵盖了手柄、键盘和鼠标的大部分的基础操作。接着让我们继续封装轴输入、滚轮和鼠标位置三种特殊的输入。滚轮的值是一个Vector2类型,不过我们要的往往是纵轴滚轮或者横轴滚轮,不会同时检测,所以我个人认为滚轮拆分为两个float值更合适一些。那么同时轴输入也是一个float值,所以这两个可以被合并为ValueInput
public abstract class ValueInput{
    public abstract float value{get;}
}
public class ScrollInputX: ValueInput{
    public override float value{get => Input.mouseScrollDelta.x; }
}
public class ScrollInputY: ValueInput{
    public override float value{get => Input.mouseScrollDelta.y; }
}
public class AxisInput: ValueInput{
    private string axisName;
    public override float value{get => Input.GetAxis(axisName); }
    public AxisInput(string name){
      this.axisName = name;
    }
    public void resetName(string name){
      this.axisName = name;
    }
}
由于鼠标滚轮是一个指定的输入,所以并不需要和什么键位绑定。同理,鼠标位置也是一个概念。由于只有鼠标位置这一类是Vector2类型的输入,所以直接自成一体即可。
public class MousePosition{
    public Vector2 pos{get => Input.mousePosition; }
}
这样三类输入都封装完毕了(暂时不考虑移动端的输入情况),我们可以重新调整ControllerBase<T>的代码了。
public enum PlayerOperations{
        FIRE,
        JUMP,
        //...
}
public class ControllerBase<T> where T: System.Enum{
    /* 将所有的映射都写到字典中,以方便改键 */
   
    private Dictionary<T, PushKey> pushKeyMaps;
    private Dictionary<T, ValueInput> valueInputMaps;
    private Dictionary<T, MousePosition> mousePositionMaps;
    public ControllerBase(){
      pushKeyMaps = new Dictionary<T, PushKey>();
      valueInputMaps = new Dictionary<T, ValueInput>();
      mousePositionMaps = new Dictionary<T, MousePosition>();
    }
   
    public bool isKeyDown(T key){
      return pushKeyMaps.isDown;
    }
    public bool isKeyUp(T key){
      return pushKeyMaps.isUp;
    }
    public bool isKeyPushing(T key){
      return pushKeyMaps.isPushing;
    }
    public float value(T key){
      return valueInputMaps.value;
    }
    public Vector2 pos(T key){
      return mousePositionsMaps.pos;
    }
}
3.2 关于阻断策略

ControllerBase<T>并不是一个可以直接使用的控制器,它还没有被阻断,所以ControllerBase<T>应该被作为一个核心输入,放到一个可以自我阻断的控制器当中ControllerBlocker<T>,有一个简单的要求就是,我们只希望初始化一个ControllerBase<T>,毕竟再初始化一个基础控制器实在是多此一举了,这要求ControllerBlocker<T>的阻断是自我屏蔽,而非屏蔽ControllerBase<T>的输入
对于那些已经被阻断的输入,就默认输出false或者0
public class ControllerBlocker<T>{
    private ControllerBase<T> sourceInput;
    private Func<float, T> __fetch_value;
    private Func<bool, T> __fetch_keydown;
    private Func<bool, T> __fetch_keyup;
    private Func<bool, T> __fetch_keypushing;
    private Func<Vector2, T> __fetch_pos;
   
    public ControllerBlocker<T>(ControllerBase<T> src){
      this.sourceInput = src;
    }
    private float __disabled_value(T key){
      return 0;
    }
    private bool __disabled_pushkey(T key){
      return false;
    }
    private Vector2 __disabled_pos(T key){
      return Vector2.zero;
    }
   
    /* 对外的API */
    public void setEnabled(bool value){
      if(value){
            __fetch_value = sourceInput.value;
            __fetch_keydown = sourceInput.isKeyDown;
            __fetch_keyup = sourceInput.isKeyUp;
            __fetch_keypushing = sourceInput.isKeyPushing;
            __fetch_pos = sourceInput.pos;
      }else{
            __fetch_value = __disabled_value;
            __fetch_keydown = __disabled_pushkey;
            __fetch_keyup = __disabled_pushkey;
            __fetch_keypushing = __disabled_pushkey;
            __fetch_pos = __disabled_pos;
      }
    }
    public bool isKeyDown(T key){
      return __fetch_keydown(key);
    }
    public bool isKeyUp(T key){
      return __fetch_up(key);
    }
    public bool isKeyPushing(T key){
      return __fetch_keypushing(key);
    }
    public float value(T key){
      return __fetch_value(key);
    }
    public Vector2 pos(T key){
      return __fetch_pos(key);
    }
}
这样,通过setEnabled函数我们就可以控制ControllerBlocker<T>的开关了,这种实现方式可能并不是最好的,不过这个部分只要能够完成对输入的阻断就可以了。实现方式可以轻松的替换为不同的策略。
3.3 关于多类型控制策略

在考虑针对不同的被控制对象来调整策略之前,我们应该先建立一个控制管理系统来注册所有的按键,所以先给ControllerBase<T>增加一些重载函数用于注册不同的按键
public class ControllerBase<T>{

        //.. properties
       
        //.. methods
       
        // register functions
    public void register(T key, PushKey pushKey){
      if(pushKeyMaps.Contains(key)){
            pushKeyMaps = pushKey;
      }else{
            pushKeyMaps.Add(key, pushKey);
      }
    }
    public void register(T key, ValueInput valueInput){
      if(valueInputMaps.Contains(key)){
            valueInputMaps = valueInput;
      }else{
            valueInputMaps.Add(key, valueInput);
      }
    }
    public void register(T key, MousePosition pos){
      if(mousePositionMaps.Contains(key)){
            mousePositionMaps = pos;
      }else{
            mousePositionMaps.Add(key, pos);
      }
    }
}
接着,构建一个控制管理系统,用于初始化基础控制器,并向其中注册按键,以及提供阻断器的载体。
public class ControllerManager{
        private ControllerBase<PlayerOperations> sourceInput;
   
    public ControllerManager(){
      sourceInput = new ControllerBase<PlayerOpations>();
      
      // 注册所有的按键
      sourceInput.register(PlayerOpertaions.JUMP, new KeyboardKey(KeyCode.Space));
      sourceInput.register(PlayerOperations.FIRE, new MouseKey(0));
    }
}
Ok,那么到此为止,这个简易系统就完成的差不多了,总的来说没有太复杂的部分,就是一些基础的封装。那么我们还需要在这个基础之上,提供游戏中所需要的控制器原型。
这个控制器原型指的是,让操作只专注于玩家要操作的那部分内容。假设用手柄来玩游戏,遇到在商店购买道具,或者打开背包等情景时,其实并不希望关注到这些操作绑定的按键是什么,也不希望知道操作的具体枚举值是什么,只希望由针对商店的操作衍生出一个虚拟的控制器原型,当游戏开始时,将这个原型分发给各个要被控制的对象。


将阻断器作为输入再放到控制器原型中,由原型挑选出关注的按键,以此构建出一个固定的上层控制器。这样,无论下层的控制按键或者控制器原型如何改变,上层控制器始终不变,对于其他任何一个UI实体或者被控制的实体来说,只需要在初始化这个单位的时候给它绑定一个控制器原型,并在需要启动它的时候打开阻断器的开关即可。
作为案例的话,我们可以尝试将商店控制器原型实现出来。假设对于商店的控制器,我们只需要上下左右选择商品,确认购买一个,取消退出商店,不考虑更复杂的操作,可以先抽象出商店的控制器,然后在PlayOpertaions中增加枚举值来代表这些操作。
public enum PlayerOperations{
   
    // player controller
        FIRE,
        JUMP,
       
   
    // for store controller or other ui controller
    LEFT,
    RIGHT,
    UP,
    DOWN,
    ENSURE,
    CANCEL,
}

public interface IStoreController{
        bool left{get;}
        bool right{get;}
        bool up{get;}
        bool down{get;}
        bool ensure{get;}
        bool cancel{get;}
    void setEnabled(bool value);
}
注意将你要绑定的按键先注册到基础控制器中。
public class ControllerManager{
        private ControllerBase<PlayerOperations> sourceInput;
   
    public ControllerManager(){
      sourceInput = new ControllerBase<PlayerOpations>();
      
      // 注册玩家控制器按键
      sourceInput.register(PlayerOpertaions.JUMP, new KeyboardKey(KeyCode.Space));
      sourceInput.register(PlayerOperations.FIRE, new MouseKey(0));
      // here has more..
      
      
      // 注册商店控制器或其他UI控制器(键盘)
      sourceInput.register(PlayerOperations.LEFT, new KeyboardKey(KeyCode.A));
      sourceInput.register(PlayerOperations.RIGHT, new KeyboardKey(KeyCode.D));
      sourceInput.register(PlayerOperations.UP, new KeyboardKey(KeyCode.W));
      sourceInput.register(PlayerOperations.DOWN, new KeyboardKey(KeyCode.S));
      sourceInput.register(PlayerOperations.ENSURE, new KeyboardKey(KeyCode.Space));
      sourceInput.register(PlayerOperations.CANCEL, new KeyboardKey(KeyCode.Escape));
    }
}
有了按键注册,有了控制器原型,可以开始实现一个商店控制器的实例了。注意,由于我们注册的按键都是键盘的,所以我们的这个实例也应该是键盘的,手柄控制器需要根据商店控制器原型来构建新的类。
public class StoreControllerKM<T>: IStoreController{
    private ControllerBlocker<T> blocker;
    public StoreControllerKM(ControllerBlocker<T> blocker){
      this.blocker = blocker;
    }
    public void setEnabled(bool value){
      blocker.setEnabled(value);
    }
        public bool left{ get => blocker.isKeyDown(PlayerOperations.LEFT); }
        public bool right{get => blocker.isKeyDown(PlayerOperations.RIGHT); }
        public bool up{get => blocker.isKeyDown(PlayerOperations.UP); }
        public bool down{get => blocker.isKeyDown(PlayerOperations.DOWN); }
        public bool ensure{get => blocker.isKeyDown(PlayerOperations.ENSURE); }
        public bool cancel{get => blocker.isKeyDown(PlayerOperations.CANCEL); }
}
这样,所有的下层元素都被封装到了控制器原型,对于上层对象来说,只需要关注控制器的具体操作即可。当然,实际开发中我们其实不用具体的分商店控制器或者背包控制器啥的,因为UI的控制大多数都是通过这几个按键来实现的,所以很多UI都是可以通用的。尽管我们可能初始化多个阻断器和多个原型控制器,但是也只用到了一个基础控制器而已。
之后我们在控制管理系统里初始化这个控制器原型,后续游戏管理器可以先初始化控制管理系统,然后在创建其他实体单位的时候,将每个控制器原型分发给不同的实体对象,并根据游戏状态来阻断这些控制器的输入。一般来说,同一时间只会有一个或者两个控制器活跃。
public class ControllerManager{
        private ControllerBase<PlayerOperations> sourceInput;
    internal IStoreController storeControllerKM;
   
    public ControllerManager(){
      sourceInput = new ControllerBase<PlayerOpations>();
      
      // 注册玩家控制器按键
      sourceInput.register(PlayerOpertaions.JUMP, new KeyboardKey(KeyCode.Space));
      sourceInput.register(PlayerOperations.FIRE, new MouseKey(0));
      // here has more..
      
      
      // 注册商店控制器或其他UI控制器(键盘)
      sourceInput.register(PlayerOperations.LEFT, new KeyboardKey(KeyCode.A));
      sourceInput.register(PlayerOperations.RIGHT, new KeyboardKey(KeyCode.D));
      sourceInput.register(PlayerOperations.UP, new KeyboardKey(KeyCode.W));
      sourceInput.register(PlayerOperations.DOWN, new KeyboardKey(KeyCode.S));
      sourceInput.register(PlayerOperations.ENSURE, new KeyboardKey(KeyCode.Space));
      sourceInput.register(PlayerOperations.CANCEL, new KeyboardKey(KeyCode.Escape));
      
      storeControllerKM = new StoreControllerKM<PlayerOperations>(new ControllerBlocker<PlayerOperations>());
    }
}
4.总结

这篇博客只是记录我个人的一些实现方案或者架构设计实践,并不是什么教程更不是什么标准答案。如果你看到了这篇文章,请仅当参考,我相信比这个更好的封装或者插件比比皆是。如果对代码中有问题或者有改进的思路,欢迎在评论区留言。感谢!
页: [1]
查看完整版本: Unity原始输入系统封装