FeastSC 发表于 2022-6-13 10:34

基于Unity.InputSystem和下推状态机的输入系统解决方案

在之前写了一篇关于原始输入系统封装的文章,里面提到了对Unity的传统Input系统控制的方法,我提出了两个基本问题去解决,一是解决Input支持改键的功能,二是当有多个对象需要被控制时,需要将别的控制器阻断,同一时间只允许一个控制器运行。
在这个基础之上,我增加了一个虚拟控制器的概念,通过接口的方式来让避免上层业务代码被底层API的变动污染。比如我可以定义一个角色的虚拟控制器接口。
public interface IPlayerInput{
    /* 虚拟的角色控制器(virtual player controller ) */
   
    float moveFactor{get;}                // 左右摇杆输入
    bool isFire{get;}
    bool isJump{get;}
    bool isRun{get;}
   
    //more code here..
}
对于上层对象来说,它只需要在一个输入循环中检测情况去根据输入来调整状态即可。
void Update(){
    // userInput是IPlayerInput类型
   
    if(playerInput.isFire){
      fire();
    }else if(playerInput.isJump){
      jump();
    }
    // more detect here..
}
对于底层代码来说,需要根据底层API来实现这些接口,对于键盘鼠标,手柄,触屏操作来说,这些控制API可能都不一样。但是它们只需要对接到这些虚拟控制器即可。
public class KmInput: IPlayerInput{

        public float moveFactor => Input.GetAxis("Horizontal");
    public bool isFire => Input.GetMouseButtonDown(0);
    public bool isJump => Input.GetKeyDown(KeyCode.Space);
   
    // more interface code here..
}
这套逻辑工作的非常漂亮,甚至切换为Unity.InputSystem时,它们也可以无缝的衔接。具体的内容可以参考我之前的文章。
那么在经过长时间的思考和打磨之后,我自己游戏中的输入系统已经迭代了三个版本了,期间有很多我觉得很棒的想法由于时间问题没有记录成文章,现在我觉得我现在使用的这套控制系统应该可以稳定的运行下去了。所以写一篇简单的文章总结一下。
文章分为两个部分,首先我会先探讨一下Unity.InputSystem的基本功能,所以如果不了解该系统的读者可以先去了解一下,其次我将提出一个新的问题以及如何去解决它。这里面涉及到对上述虚拟控制器的改进和优化,以及更多功能的追加。
1.关于Unity.InputSystem

和我之前写的文章类似,Unity的新版输入系统的主要作用也是提供以下的基本功能

[*]支持多个控制设备的按键绑定到一个虚拟按键上
[*]控制器可以被禁用
[*]支持绑定按键的变更
除了以上几个点,InputSystem有一个很大的变更就是,它目前来说更加支持给虚拟按键绑定一个回调函数。当按键被按下时,它会执行该回调函数。这与Unity传统的输入检测大不相同,因为旧版的输入系统更加底层,控制模式更多是循环检测+执行函数。不过它仍然以支持旧版的模式来访问,回调函数可以不绑定,在我自己的系统里,由于上层控制对象的复杂多样,所以并不是所有对象的控制按键都可以通过一个回调函数来解决问题。所以我仍然以循环检测的形式来进行控制。不过未来我有可能会对这个部分进行再升级。
1.1 多个控制设备绑定到同一个虚拟按键

InputSystem允许用户创建多个ActionMap,每个Map都是一个控制器,而每个ActionMap又有多个Action组成,ActionMap可以理解为不同的控制器,比如游戏可能会有角色控制器,UI控制器,小游戏控制器等。而Action就是虚拟按键,每个虚拟按键又支持绑定一个或者多个设备输入。
这是一个非常有用的工具,可以便捷的管理游戏所支持的设备。不需要再写大量的代码去管理和控制了,而且所绑定的按键可以直接通过InputSystem插件生成的代码以C#属性的形式来访问。


我创建了一个名为Player的ActionMap,然后创建了一个名为Jump的Action,并为其绑定了两个按键,键盘的空格键和Xbox手柄的A键。


它可以自动生成一个脚本(此处我命名为InputSource),后续我们可以通过可以直接new一个InputSource对象来访问所有的ActionMap和每个ActionMap的按键值。代码如下所示
using UnityEngine;

public class Test: MonoBehaviour{

    InputSource source;
    void Start(){
      source = new InputSource();
      source.Enable();
    }
    void Update(){
      if(source.Player.Jump.triggered){
            Debug.Log("Jump Key has been triggered");
      }
    }
}
1.2 阻断输入

ActionMap直接有原生的方法支持禁用,所以当一个ActionMap不需要激活时可以禁用它来阻断所有输入,不仅是ActionMap,如果所有的控制器都不需要使用的话,可以直接禁用这个ActionMap组。
// inputSource.Disable();
// inputSource.Player.Disable();
1.3 切换绑定按键

Action所绑定的按键值是可以变化的,预设值是手动设置的,变化值可以通过代码的方式来切换。
private void rebindKey(){
       
    source.Player.Jump.Disable();
    source.Player.Jump.PerformInteractiveRebinding().OnComplete(operation => {
      
      operation.Dispose();
      Debug.Log("rebind complete");
      source.Player.Jump.Enable();
    }).Start();
}
不过这些InputSystem的features不属于本文的讨论范围,所以关于运行时按键绑定更多可以参考下面的视频。
2.一个基于InputSystem的控制器管理方案

InputSystem已经解决了大部分的问题了,所以剩下的问题基本上就只有该怎么管理这些控制器了。或者说上层对象是如何被控制的。那么我提供一个我认为有效的解决方案供大家参考,为了方便标记,我暂时给该方案起名为DRock,它提供了一组简单的工具,用于管理所有的游戏对象或后台对象。
首先基本架构还是先确定整个游戏仍然是每个控制器通过循环检测按键值的方式来控制,而非设置回调函数,因为在一定程度上,因为即便设置回调函数,我们仍然需要主动去读取这些值。比如类似于<MousePosition>,或者<HorizontalValue>等,游戏结构的复杂使得回调函数的设计会很大程度上阻碍游戏逻辑的编写,而对于控制器来说,它应该是简单易用而且一种形式。
2.1 桥接InputSystem和游戏控制逻辑

首先我们要解决的第一个问题就是,解决游戏上层控制逻辑和底层API的耦合问题。这个问题无论我们选择回调函数还是循环检测都会存在。简单来说,假设我们有多个不同的游戏对象要控制,其中包含了各类游戏UI,角色对象,小游戏等等。
如果每个对象都要编写一套针对InputSystem的回调函数,或者都要循环读取InputSystem的键值,都会使得InputSystem和游戏上层控制逻辑高度耦合。这样底层发生变动时,比如多增加一个按键,那么每个被控制的对象,无论需不需要这个按键,都会被迫去实现它的回调函数。
那么我们的做法就是,用一个代理控制器来接受InputSystem的控制,然后这个代理对象再去控制上层游戏对象。这样的话,上层游戏对象就只需要和我们编写的代理对象交流即可。反过来说,InputSystem也只需要和代理对象对话即可。这个代理对象,我习惯于称其为桥接对象。我们可以将其命名为DRockBridge。


我们之前所设置的虚拟控制器,本质上是一种桥接对象,它桥接了底层的输入API和上层的游戏逻辑。也就是说,我们的Unity的输入系统只需要控制这个桥接对象,再由桥接对象处理输入系统的信息,最后控制游戏上层逻辑。
不过虽说是桥接,但其实它所做的工作非常简单,只是将InputSystem的数值读取出来,以C#属性的方式发放给上层的游戏逻辑使用而已,如下代码所示。
public class DRockBridge{

    private InputSource.PlayerActions playerActions;
   
    // 所有可以访问的键值
    public bool isJump => playerActions.Jump.triggered;
}
这样的转接在一定程度上避免了由于InputSystem的ActionMaps的变动导致的代码调整,尤其是当上层控制对象数量较多的时候。
2.2 游戏控制逻辑的维护性问题

为什么要用状态机来管理所有控制对象,简单来说,大部分的时候只有一个控制器是有效的,而这个控制器被激活时也往往伴随着它要控制的GameObject的激活。比如打开主菜单时,代码需要激活主菜单,与此同时关闭角色控制器并打开UI控制器。
public void openMainMenu(){
    // 伪代码-打开主菜单
   
    game.MainMenu.show();
    playerInput.Disable();
    uiInput.Enable();
}
这个设计不好的地方有两个,第一是,游戏主菜单和游戏UI控制器的关系是被这一个函数绑定在一起的,在某种程度上,它们的绑定关系应该更加紧密一点。不过这样还不足以产生太大的问题,但是伴随着主菜单的打开,如果我希望开启一个毛玻璃的特效,必须继续往这个函数内加入新的代码。
public void openMainMenu(){
    // 伪代码-打开主菜单
   
    game.MainMenu.show();
    playerInput.Disable();
    uiInput.Enable();
    game.postProcessing.enableGaussBlur()                // 打开高斯模糊
}
同样的,我们还需要关闭一些其他的常驻UI以免挡住主菜单UI。
public void openMainMenu(){
    // 伪代码-打开主菜单
   
    game.MainMenu.show();
    playerInput.Disable();
    uiInput.Enable();
    game.postProcessing.enableGaussBlur()                // 打开高斯模糊
    game.ui.closePermanentUi();                                        // 关闭常驻UI
}
在主菜单的某个选项选中时,有可能会打开一个子菜单,也有可能会进入一个特殊的模式,比如有的游戏存在建造模式,或者打开了什么商店等。假设我们把打开一个子菜单也视为一个游戏API功能的话,肯定要为其单独增加一个函数。首先肯定要关闭主菜单,其次如果我们打开的不是子菜单,而是某个特殊的模式,还要启动对应的控制器,并关闭现有的控制器。
不过就先假设为打开子菜单吧,其他的特效也好,常驻UI也好都维持和主菜单一致的状态。
public void openSubMenu(){
        // 伪代码-打开子菜单
   
    game.MainMenu.hide();
    game.SubMenu.show();
}
此时如果要退出子项的话,应该调用openMainMenu还是game.MainMenu.show,如果子项是一个子菜单,那肯定是game.MainMenu.show因为其他东西都是一致的,特效和背景啥的主菜单一样是存在的。如果子项不是一个菜单,而是一个什么别的东西,或者openMainMenu更好一点,因为我们在打开这样的子项的时候,就随手关闭了特效和UI什么的。
接着,随着菜单、选项、游戏模式越来越多。这个东西就会越来越复杂,代码也变得更加的不可维护。所以讲道理,我们需要一个更加solid的解决方案。
2.3 通用状态机

所以为了解决上述问题,可以将控制器和被控制对象视为一个整体,或者一整个状态。这样启动控制器的同时就启动被控制对象。这样会使得控制器的阻断更加紧凑,或者说,不再需要手动管理。
这样做更好的一点是,此后我们可以将游戏的控制逻辑视为多个状态。这样只要定义好数个状态对象,此后管理所有的状态对象即可。不需要再在单独的函数中设置输入是否关闭或者开启。


比如这里有三个状态,玩家状态,UI状态和其他状态(这里的其他状态的意思就是泛指任何通过不同控制器控制的状态),游戏可以由一个状态切换到另外的一个状态。
但是绑定一个控制器并不是真的绑定一个实体对象进来,而是绑定一个Id,比如一个int值或string值,也可以是自定义的枚举类型,无论如何,只要这个Id能够查询到对应的控制器并启动或者关闭即可。
而绑定对象就是需要绑定对象实体了,绑定实体对象有三种方法。

[*]把对象当成参数传递进来
[*]让对象继承一个接口然后以接口的形式访问对象的各类函数
[*]让对象直接继承这个状态类
我选择了第三种方法,这里面最低耦合的方法是第一种,不过解决这种耦合性没有任何意义。因为即使把对象当成参数传递进来,我们仍然需要通过别的方式来解决角色对象,UI对象等游戏实体对象之间的差异问题,无论如何都会在游戏逻辑内部或者外部产生等强度的耦合性。而第二种方法和第三种方法就是解决这种差异的手段。
选择第三种方法的好处是可以让一个抽象类存储一些通用性质的代码。
public abstract class DRockInputReceiver{

    private int id;
    public int controllerId => id;
    public DRockInputReceiver(int id){
      this.id = id;
    }
   
    // 实现该函数以接受用户输入
    public abstract void onUpdate(float deltaTime);
}
至于如何在onUpdate函数中获取输入后面再谈。
那么它以状态机的形式存在的意义更多是,我们可以明确它有进入和退出两个动作。当进入一个状态时,就默认执行它的进入的回调函数,退出时也同理。
public abstract class DRockInputReceiver{

    private int id;
    public int controllerId => id;
    public DRockInputReceiver(int id){
      this.id = id;
    }
   
    // 实现该函数以接受用户输入
    public abstract void onUpdate(float deltaTime);
   
    // 重写进入和退出时的回调函数,则默认在执行对应的状态时执行
    public virtual void onEnter(){}
    public virtual void onExit(){}
}
不过这样仍然解决不了我们前面抛下的问题,考虑这样的一个问题,由玩家状态进入主菜单时(此时执行了主菜单的onEnter函数),我们需要打开背景特效,由主菜单退出到玩家状态时(此时执行了主菜单的onExit函数),我们关闭了背景特效。但是如果我们由主菜单进入到子菜单时(也执行了onExit函数)同时也受到影响关闭了特效。
(注意这里的背景特效这个概念只是一个简单的案例,它可以泛指那些随着被控制对象变化而引起的渲染变化)
所以其实某些情况下需要更加细节的控制方法。
2.4 基于下推状态机的控制策略

所以我们需要给这个状态机增加一个约束,即它是一个下推状态机。它本质是一个栈区和一个临时位构成。并通过推入和弹出两个基本动作来控制所有对象。
当往一个空的下推状态机管理器中推入一个状态对象时(当前案例中就是DRockInputReceiver),那么它会被存储于临时位,如下图所示。


当继续推入新的状态时,旧的状态会存储到栈区,而新的状态会被存储于临时位。


以此类推。


我们可以不断地往这个栈区推入新的状态,并且只有临时位的状态会获取控制权限。同样的,当弹出一个状态时,会将临时位的状态置空,并从栈区弹出一个状态到临时位。
这样做有什么意义呢?仔细思考可以发现,这样做的话,每个状态就多了两个动作。

[*]当状态从外界被推入临时位时,执行onEnter函数
[*]当状态从临时位被移除时,执行onExit函数
[*]当状态从临时位被压入栈区时,执行onPause函数
[*]当状态从栈区被回滚到临时位时,执行onResume函数
所以这样一来,我们就可以更加细节的控制每个状态所面临的退出概念,到底是往前打开子菜单,还是往后直接关闭了。(案例是UI对象是因为我就是从菜单中获取的灵感)
据此我们可以填充之前的代码。
public abstract class DRockInputReceiver{

    private int id;
    public int controllerId => id;
    public DRockInputReceiver(int id){
      this.id = id;
    }
    public abstract void onUpdate(float deltaTime);
    public virtual void onEnter(){}
    public virtual void onExit(){}
    public virtual void onPause(){}
    public virtual void onResume(){}
}
2.5 获取输入

获取输入的方式有两种,低耦合的方式可以通过一个固定的接口来实现。
public interface DRockUserInput{

    bool getKeyDown(string name);
        bool getKey(string name);
    float getValue(string name);
    Vector2 getVector2(string name);
}
有点类似于旧版本的Input系统,不过具体实现就是通过读取InputActions的键了。新版的InputSystem同时支持通过字符串的方式获取Action对象,这点真是太厉害了,下面是一个案例。
public bool getKeyDown(string name){
    // source就是ActionMaps对象
   
        return source.asset.triggered;
}
public float getValue(string name){
   
    return source.asset.ReadValue<float>();
}
不过其实没有必要这么谨慎,因为游戏按键相对游戏开发来说是比较懒的一个部分,它并不需要频繁的变动。因而直接写成一个C#的属性也没啥问题。因为整个系统都是一个基于InputSystem的轻量级架构,大部分的麻烦事情都在InputSystem中解决了。
当然,如果用的是老版的Input对象,其实也可以套用这个架构,这就是桥接对象存在的意义。是的,都可以桥。
有了这个接口之后,直接塞入DRockInputReceiver中就可以了。
public abstract class DRockInputReceiver{

    private int id;
    public int controllerId => id;
        public DRockUserInput userInput;
    public DRockInputReceiver(DRockUserInput input, int id){
      this.id = id;
      this.userInput = userInput;
    }
    public abstract void onUpdate(float deltaTime);
    public virtual void onEnter(){}
    public virtual void onExit(){}
    public virtual void onPause(){}
    public virtual void onResume(){}
}
随后在其他对象继承了该对象之后,实现其onUpdate函数,就对接完成了。
public override void onUpdate(float deltaTime){

        if(userInput.getKeyDown("Jump")){
      jump();
    }
}
2.6 下推状态机的基本实现

这个下推状态机其实非常简单,只是一个超轻量级的数据结构而已。首先是一个临时位和一个栈区。
public class DRockPushdownFSM{

    private DRockInputReceiver current;
    public IDRockInputReceiver currentReceiver => current;
    private Stack<DRockInputReceiver> stack;
    public void push(DRockInputReceiver receiver){}
    public void pop(){}
}
对于推入来说,就是检查当前临时位有没有数据,有的话就压入栈区,没有就直接写入即可。然后重点是调用它们对应的动作函数。
public void push(DRockInputReceiver receiver){

    receiver.onEnter();
    if(current != null){
      current.onPause();
      stack.Push(current);
    }
    current = receiver;
}
然后pop函数就是逆操作了。
public void pop(){

    current.onExit();
    if(stack.Count > 0){
      current = stack.Pop();
      current.onResume();
    }
}
不过还得同时控制输入系统的启动和关闭,这个功能对于下推状态机来说其实是一个不太关心的操作,或者说,它本质上是底层API的如何处理的问题,直接给状态机一个对应的委托即可。
public class DRockPushdownFSM{

    private DRockInputReceiver current;
    public IDRockInputReceiver currentReceiver => current;
    private Stack<DRockInputReceiver> stack;
    private Action<int> setCurrentInput;
    public DRockPushdownFSM(Action<int> setCurrentInput){

      this.setCurrentInput = setCurrentInput;
    }
    public void push(DRockInputReceiver receiver){

      // ..
    }
    public void pop(){

      // ..
    }
}
setCurrentInput是一个互斥的函数,它的功能是激活给定的控制器Id,并关闭其他的控制器。这个功能不属于InputSystem的feature,而是属于游戏层面的设计。也不一定非要是互斥的,只是我个人认为互斥将会更加有效的杜绝一些莫名其妙的bug问题。
那么相对应的,push和pop函数也需要改动,如下所示:
public void push(DRockInputReceiver receiver){

    receiver.onEnter();
    if(current != null){
      current.onPause();
      stack.Push(current);
    }
    setCurrentInput(receiver.controllerId);
    current = receiver;
}
public void pop(){

    current.onExit();
    if(stack.Count > 0){
      current = stack.Pop();
      current.onResume();
      setCurrentInput(current.controllerId);
    }
}
2.7 状态的扩展

我们当前的状态设计是独立的类,不过Unity中大部分的类都是基于MonoBehaviour的,所以需要增加一个新的从MonoBehaviour衍生的对象。
public abstract class DRockUnityInputReceiver: MonoBehaviour{

    private int id;
    public int controllerId => id;
    public DRockUserInput userInput;
    public virtual void init(DRockUserInput input, int id){
      this.id = id;
      this.userInput = userInput;
    }
    public abstract void onUpdate(float deltaTime);
    public virtual void onEnter(){}
    public virtual void onExit(){}
    public virtual void onPause(){}
    public virtual void onResume(){}
}
除了初始化函数由构造函数变成了一个虚函数之外,其他基本不变。不过问题就是这样会面临一个新的问题,就是下推状态机只能存储一个类型。所以还是需要一个接口的来对接下推状态机的。


public interface IDRockInputReceiver{

    int controllerId{get;}
    void onUpdate(float deltaTime);
    void onEnter();
    void onExit();
    void onPause();
    void onResume();
}
有这几个属性,就可以被状态机管理了。然后给DRockInputReceiver和DRockUnityInputReceiver套上就完事了。
public abstract class DRockInputReceiver: IDRockInputReceiver{
    //..
}
public abstract class DRockUnityInputReceiver: MonoBehaviour, IDRockInputReceiver{
    //..
}
2.8 桥接对象和下推状态机的整合

最后我们把桥接对象和下推状态机整合到一起就完事了,为了聊胜于无的模块化应用,可以将其分为两个部分,第一个部分用于定义一些基本函数,第二个部分用于容纳脏代码。
public abstract class DRockBridge: DRockUserInput{

    private DRockPushdownFSM fsm;
    public DRockBridge(){
      fsm = new DRockPushdownFSM(setCurrentInput);
    }
    protected abstract void setCurrentInput(int id);
    public abstract bool getKeyDown(string name);
    public abstract bool getKey(string name);
    public abstract float getValue(string name);
    public abstract Vector2 getVector2(string name);
    public void push(IDRockInputReceiver receiver){
      fsm.push(receiver);
    }
    public void pop(){
      fsm.pop();
    }
    public void onUpdate(float deltaTime){
      if(fsm.currentReceiver != null){
            fsm.currentReceiver.onUpdate(deltaTime);
      }
    }
}
第二个部分就是针对游戏中的脏代码了,也就是无法通用的代码。不过这部分我们留到第三节来演绎。
2.9 检测输入设备的变化

独立游戏中经常需要实时监测游戏输入设备是否发生变化以将UI操作提示更换。这个操作纯粹是属于InputSystem的API问题了,我就不做过多解释了,基本做法就是给InputSystem.onActionChange监听器追加一个回调函数,回调函数将会在每次按键(无论设备)按下时执行,如果检测到的设备和现有设备不符合的话,就回调给定的函数。
我自己的项目代码如下:
    private void detectDevice(object sender, InputActionChange change){
      /* NOTE> 检查输入设备有没有发生变化 */

      # if DEBUG
      // WARN> DEBUG版本中遇到未知的设备时,报告未知的设备信息
      if(change == InputActionChange.ActionPerformed){
            InputDevice d = ((InputAction)sender).activeControl.device;
            if(deviceLut.ContainsKey(d.displayName)){
                DolocInputDeviceType type = deviceLut;
                if(currentDeviceType != type){
                  currentDeviceType = type;
                  onInputDevicedChanged(currentDeviceType);
                }
            }else{
                //NOTE> 遇到位置设备时进行报备
                DolocAPI.outputc($"遇到未知设备Id<{d.displayName}>", DolocColor.red);
            }
      }
      #else
      // NOTE> 正式版本中,不做安全检查
      if(change == InputActionChange.ActionPerformed){
            InputDevice d = ((InputAction)sender).activeControl.device;
            DolocInputDeviceType type = deviceLut;
            if(currentDeviceType != type){
                currentDeviceType = type;
                onInputDevicedChanged(currentDeviceType);
            }
      }
      #endif
    }
然后将该函数追加到InputSystem的onActionChange回调里即可。
UnityEngine.InputSystem.InputSystem.onActionChange += detectDevice;
3.一个基本的案例

3.1 API的规范

上述代码如何运用,对外来说其实非常简单,只需要继承DRockInputReceiver并实现你需要实现的函数,当需要控制这个对象的时候,只需要将这个对象push到下推状态机即可
bridge.push(yourObj);
实现相对应的函数时根据状态来选择,比如有的函数并不需要实现onPause和onResume函数就不需要管了。
3.2 一个简单的实践

有了上述的实现之后,我们来做一个简单的案例。案例里面,我们设两个方块,两个方块通过不同的控制器控制,一个可以左右移动,一个只能跳跃,但是同一时间只能控制一个,当按手柄的Start按键之后,交替到另外一个。当一个方块被激活时,它的色彩将变成红色。
首先第一件事就是重新编辑一下ActionMap,具体操作可以自己百度。




当然,这个可能会带来一些误导就是觉得Action应该放在两个ActionMap里,但其实Move和Jump都应该放在同一个ActionMap里,这里是强行分为两个ActionMap只是为了测试我们的代码而已。
下面的代码是桥接对象的实现,注意这里面的setCurrentInput函数只是一种脏代码实现而已,也就是说在复杂情况下,这种写法会迅速失去作用。
public class DRockManager: DRockBridge{

    private InputSource.BoxJumpActions jumpAction;
    private InputSource.BoxMoveActions moveAction;
    private InputSource source;

    public DRockManager(){
      source = new InputSource();
      source.Enable();
      jumpAction = source.BoxJump;
      moveAction = source.BoxMove;
    }
    protected override void setCurrentInput(int id){
      switch(id){
            case 0:
            moveAction.Enable();
            jumpAction.Disable();
            break;

            case 1:
            moveAction.Disable();
            jumpAction.Enable();
            break;
      }
    }
    public override bool getKeyDown(string name){
      return source.asset.triggered;
    }
    public override bool getKey(string name){
      return source.asset.inProgress;
    }
    public override float getValue(string name){
      return source.asset.ReadValue<float>();
    }
    public override Vector2 getVector2(string name){
      return source.asset.ReadValue<Vector2>();
    }
}
这样就足够了,随后我们编写两个简单的脚本用于控制Cube对象。
public class Player: DRockUnityInputReceiver{

    private float moveSpeed;
    private MeshRenderer rd;
    private GameManager game;
    public void init(GameManager game, DRockUserInput userInput){
      this.game = game;
      base.init(userInput, 0);         // 0号控制器
      rd = GetComponent<MeshRenderer>();
    }
    public override void onEnter(){
      rd.sharedMaterial.SetColor("_BaseColor", Color.red);
    }
    public override void onExit(){
      rd.sharedMaterial.SetColor("_BaseColor", Color.white);
    }
    public override void onPause(){
      rd.sharedMaterial.SetColor("_BaseColor", Color.blue);
    }
    public override void onResume(){
      rd.sharedMaterial.SetColor("_BaseColor", Color.red);
    }
    public override void onUpdate(float deltaTime){
      Vector2 factor = userInput.getVector2("Move");
      transform.Translate(new Vector3(factor.x, 0, factor.y) * moveSpeed * deltaTime);
      
      if(userInput.getKeyDown("Switch")){
            game.pdfsm.push(game.subPlayer);
      }
    }
}
尽管它是一个MonoBehaviour对象,但是我没有使用它的Update函数,为了方便管理和控制,我们应该尽量保持项目只有一个Update函数,它最好放置于GameManager对象中。另外一个脚本和它基本类似,主要是onUpdate和控制器Id不同。
public class AnotherPlayer: DRockUnityInputReceiver{

    private float jumpForce;
    private MeshRenderer rd;
    private Rigidbody rb;
    private GameManager game;
    public void init(GameManager game, DRockUserInput userInput){

      this.game = game;
      base.init(userInput, 1);         // 0号控制器
      rd = GetComponent<MeshRenderer>();
      rb = GetComponent<Rigidbody>();
    }
    public override void onEnter(){
      rd.sharedMaterial.SetColor("_BaseColor", Color.red);
    }
    public override void onExit(){
      rd.sharedMaterial.SetColor("_BaseColor", Color.white);
    }
    public override void onPause(){
      rd.sharedMaterial.SetColor("_BaseColor", Color.blue);
    }
    public override void onResume(){
      rd.sharedMaterial.SetColor("_BaseColor", Color.red);
    }
    public override void onUpdate(float deltaTime){
      if(userInput.getKeyDown("Jump")){
            rb.velocity = new Vector3(0, jumpForce, 0);
      }else if(userInput.getKeyDown("Switch")){
            game.pdfsm.pop();
      }
    }
}
最后是游戏管理器
public class GameManager: MonoBehaviour{

    private Player player;
    private AnotherPlayer anotherPlayer;

    public Player mainPlayer => player;
    public AnotherPlayer subPlayer => anotherPlayer;

    public DRockManager pdfsm;
    void Start(){
      pdfsm = new DRockManager();
      player.init(this, pdfsm);
      anotherPlayer.init(this, pdfsm);
      pdfsm.push(player);
    }
    void Update(){
      pdfsm.onUpdate(Time.deltaTime);
    }
}
把两个控制脚本都交给它,并且创建一个新的下推状态机,在保持序列状态下,需要控制另外一个对象时,只需要根据将它推入状态机或者从状态机栈区弹出即可。


4.总结

我知道在知乎发技术性的文章总是会受到各种质疑,你这里不应该这么搞,你这样做耦合性太高了,或者你这样做根本不对。这样的东西我实在是听的有点烦了,所以我希望在这里做一个声明,这篇文章是笔记,它并不是教程文章,也并非追求高强度的通用,请理性讨论。
其实在我自己的项目中,这个结构由于项目的控制体系非常复杂和多变,其本身也变得更加复杂,这里的代码只是一个核心思想。但无论如何,它几乎完美的承载着项目的需求。而且非常便于维护性和拓展,当然,在我的项目中,下推状态机变得更复杂一点,它拥有更多的对栈区的控制方式。也可以直接替换当前的临时位,这些部分可以尝试继续优化和探索。
该文中的代码由于过于简单所以没有上传到云端。
页: [1]
查看完整版本: 基于Unity.InputSystem和下推状态机的输入系统解决方案