Unity——#20 Double Trigger & Long Press


  这节我们打算真正实现黑魂里面的跳跃机制,我先讲述的黑魂是怎么跳的。在黑魂中,它把后跳、翻滚、前跳、跑步做在了同一个按键(PC是Space,PS手柄是○)里,当角色静止不动时,玩家键入空格,将会是后跳;当玩家步行时,玩家键入空格,将会是翻滚;当玩家在奔跑(按住空格是奔跑)时,松开空格并在短时间内再次键入空格,将会是跑跳接翻滚。细想一下这真是一件非常复杂的事情。虽说复杂,但毕竟是前辈造好的轮子,我们抱着学习的心态去模仿终究不会太难。
  为了实现这个机制,我们应该实现两个检测,一个是Double Trigger,另一个是Long Press。

  • Double Trigger 按两次触发,即在短时间内快速键入同一个按钮才触发相应的动作,比较知名的例子就是按一下方向键走动,按两下方向键跑动(如DNF)。
  • Long Press 长按触发,即按住某个按键超过判断的时间范围才会触发相应的动作,如蓄力攻击,当然也有蓄力奔跑的。
      两个检测都涉及到一个非常关键的判断条件,那就是时间。触发Double Trigger的条件是在松开某个按键后(第一次键入完毕)的某个时间范围内是否有再次键入同个按键,如果有就触发,没有就不触发。触发Long Press的条件是在按住某个按键后的某个时间范围内有没有松手,没有就触发,有就不触发。
      到这我们就应该想到实现这两个检测之前,我们需要做什么前置工作,那就是实现一个键入复位反馈的机制和一个计时器。

键入复位反馈机制

  先来讨论一下何为键入复位反馈机制。玩家键入一个按钮的信号图如下:


  键入就要在玩家按下按键那一刻,在信号上升沿完成后,产生反馈;

复位就是在玩家松开按键那一刻,在信号下降沿完成后,产生反馈。

只有侦测到这两个反馈,我们才能准确地调用计时器开始计时。
  我们先来实现这个键入复位反馈。我们创建一个C# Script,命名为My Button,在里面实现我们的反馈机制,赶快搞起来。
  我们应该需要3个bool变量来当做反馈的信号,第一个是IsPressing,代表的是第一幅图,与玩家的键入信号呈完全正相关;第二个是OnPressed,代表的第二幅图;第三个是OnReleased,代表的是第三幅图。

public class MyButton {
    public bool IsPressing = false;
    public bool OnPressed = false;
    public bool OnReleased= false;
}

  我们先用文字描述一下这三个布尔值怎样实现图中的信号变换。由于我们会用到外部传进来的输入信号(Input.GetKey()诸如此类的),所以我们需要一个bool变量来记录外部进来的信号,记录为当前的信号(curState)。

  • IsPressing 这个最简单,只要跟curState一致就行了。
  • OnPressed 在信号的上升沿处说明此时按键的信号正在切换,由false变为true。所以需要现在的状态(true)与之前的状态(false)比较,如果不同且此时的状态为true,说明现在处于键入的瞬间,OnPressed设为true,否则设为false。
  • OnRealeased 在信号的下降沿处说明此时按键的信号正在切换,由true变为false。所以同样亦需要现在的状态与之前的状态比较,如果两者不一且此时状态为false,说明现在处于松手的瞬间,OnRealeased设为true,否则设为false。
      没错,我们需要一个记录当前信号的curState和记录上一次信号的lastState:
public class MyButton {
    public bool IsPressing = false;
    public bool OnPressed = false;
    public bool OnReleased= false;

    private bool curState = false;
    private bool lastState = false;
}

  我们需要一个定义一个函数来实现刚才所说的变换,命名为Tick,另外,由于它需要外部的输入信号,所以要一个bool型参数:

    public void Tick(bool input){
        curState = input;
        IsPressing = curState;

        OnPressed = false;
        OnReleased = false;

        if (curState != lastState) {
            if (curState == true) {
                OnPressed = true;
            } else {
                OnReleased = true;
            }
        }
        lastState = curState;
    }

  这里的代码与刚的文字描述大体相同,不再赘述,唯一需要说的就是,要在这个函数的最后更新lastState,因为随着这个函数结束,当前状态其实就已经是过去式了。
  现在这个键入复位反馈机制就基本完成了,去试试看能不能达到我的需求。我打算在JoystickInput.cs(手柄)里做相关测试,键盘就不再另说了,其实都是一样的。
  取手柄的任意一个按钮,这里就用□来做测试:先创建一个MyButton变量并初始化:

//在JoystickInput.cs里
    public MyButton ButtonA = new MyButton ();

  然后在Update调用它的Tick函数:

    void Update () {
        ButtonA.Tick (Input.GetKey (keyButA));
        print (ButtonA.IsPressing);
    ...
    }

  来看看IsPressing能否正常工作:



  嗯是可以的,在我没有按□时,打印false,在我按住□时,打印true。接下来再试试另外两位:

    void Update () {
        ButtonA.Tick (Input.GetKey (keyButA));
        print (ButtonA.OnPressed);
    ...
    }

  嗯也是可以的,在我没有按□时吐false,在我按下□那一刻就吐true,且即使我不松手也还是吐false。

    void Update () {
        ButtonA.Tick (Input.GetKey (keyButA));
        print (ButtonA.OnReleased);
    ...
    }

  最后一位也是不负众望。在我没按□时吐false,在我按住□时还是吐false,在我松开□那一刻吐true。这么说可能没有说服力,但是只能如此,我没有办法把我按手柄的情况拍下来上传到这里(捂脸)。
  现在键入复位反馈机制测试完毕,算是基本完工,不过以后肯定还会作出修改,因为还要联动计时器。现在的重点就来到了实现计时器上。

计时器

  在Unity里做计时器一般都会用到Unity.EngineTime.deltaTime,因为它准确的记录了游戏进行时每一帧的时间间隔,我们要做的就是把它累积起来,那么就需要一个float变量来记录累积的时间,这就是计时。除了计时,我们还要一个float变量记录检测的时间范围,正因为要有时间上要求,才会有计时,不然计时毫无意义。我们还需要3个状态值代表目前的计时情况,我取它们为IDLE、RUN、FINSHED。

  • IDLE 计时器在开始计时前为闲置状态
  • RUN 计时器开始计时后进入该状态,表明当前计时器正在计时且没超出检测的时间范围
  • FINSHED 计时器因超出预定的时间范围停止计时并进入该状态
      OK,让我们看看代码如何实现,先创建一个C# Script,命名为My Timer,
public class MyTimer{

    public enum STATE{
        IDLE,
        RUN,
        FINSHED
    }

    private float duration;    //检测时间范围
    private float elapsedTime;    //记录累积的时间
    public STATE state;    //记录当前状态
}

  接下来这个计时器也需要一个Tick()函数来真正实现计时功能,我们使用switch判断state在哪个状态,并进行相应操作。需要注意的是,如果我们要在default层报错,是不能用print输出信息的,因为我们这个类没有继承MonoBehaviour,不过可以用Unity.Engine里面的Debug.log()来输出信息。

    public void Tick(){
        switch (state) {
        case STATE.IDLE:
            break;
        case STATE.RUN:
            elapsedTime += Time.deltaTime;
            if (elapsedTime > duration) {
                state = STATE.FINSHED;
                break;
            } else {
                break;
            }
        case STATE.FINSHED:
            break;
        default:
            Debug.log("STATE Error!");      
            break;
        }
    }

  但这仅仅是计时而已,我们还需要一个开启计时的开关(把计时器状态设置为RUN),那么这个开关该放在哪里呢?我细想了一下,我把MyTimer里面的duration变量封装了,那么外界是不能访问它的(我也不想别人能轻易改变这个值),但是真正要开启计时器的是MyButton,因为只有MyButton才知道什么时候应该开启计时(它是键入复位反馈)。那么这一内一外都要有开关了。
  在外:MyButton通过开启自己的计时开关(我叫它外开关),调用内开关,把检测的时间范围送给内开关。在内:MyTimer接收外开关送来的数据并把它赋值给duration,并开启真正的计时开关(内开关)。
  在MyTimer.cs里,真正的计时开关是把计时状态设为RUN,这时switch才会进入到RUN并计时(累积deltaTime)

    public void Go(float _duration){
        elapsedTime = 0;
        duration = _duration;
        state = STATE.RUN;    //这才是真正的内开关
    }

  在MyButton.cs里,外开关要做的就是接收指定的计时器和检测时间范围。

    private void StartTimer(MyTimer curTimer, float duration){
        curTimer.Go (duration);
    }

  另外我们要在MyButton.cs里宣告两个计时器对象,一个实现Double Trigger,另一个实现Long Press。因为两者的计时区域不一样,所以要两个计时器对象。

    private MyTimer exitTimer = new MyTimer ();    //DoubleTrigger
    private MyTimer delayTimer = new MyTimer ();    //LongPress

  为了方便我之后进行测试,我现在就来解释Double Trigger和Long Press的计时区域(计时范围)在哪。在本节的开头已经有所提及:Double Trigger是短时间内快速键入同一个按钮才触发相应的动作,那么它的计时就应该在松开某个指定键开始,直到超过计时范围停止计时,在计时过程中,如果再次键入了同一个按钮,就视为触发Double Trigger。图中虚线区域就是计时范围(duration)。

Double Trigger

  Long Press是即按住某个按键超过判断的时间范围才会触发相应的动作,即它的计时应该在键入指定按钮的那一刻开始,如果超过了计时范围,即视为玩家的意图是长按,触发Long Press。
Long Press

  现在清楚了,于Double Trigger而言,开关StartTimer()应放在OnReleased为true之后;于Long Press而言,开关StartTimer()应放在OnPressed为true之后。另外要注意的是,MyButton.cs的Tick()函数是在JoystickInut.cs的Update()函数里被调用才得以推动自身状态的更新,而对于MyTimer.cs的Tick()函数,也要做同样的事情,不然在没人调用Tick()函数的情况下,计时器相当于停滞不前了。我们可以用MyButton的Tick()函数带动MyTimer的Tick()函数。

    public void Tick(bool input){

        exitTimer.Tick ();    //带动

        curState = input;
        IsPressing = curState;

        OnPressed = false;
        OnReleased = false;

        if (curState != lastState) {
            if (curState == true) {
                OnPressed = true;
                StartTimer (delayTimer, 3.0f);    //测试的是Long Press
            } else {
                OnReleased = true;
                //StartTimer (exitTimer, 3.0f);  只是测试函数的逻辑是否正确,一个计时器足矣
            }
        }
        lastState = curState;
    }

  现在可以来测试一下了,我在计时器正在计时的时候顺便打印一点信息,以便测试计时器能否正常计时,且当计时完毕后也打印一条信息,测试其是否会正常停止计时:

    public void Tick(){
        switch (state) {
        case STATE.IDLE:
            break;
        case STATE.RUN:
            elapsedTime += Time.deltaTime;
            Debug.Log ("RUNing");
            if (elapsedTime > duration) {
                state = STATE.FINSHED;
                break;
            } else {
                break;
            }
        case STATE.FINSHED:
            Debug.Log ("FINSHED");
            state = STATE.IDLE;      //新增,在计时完毕后计时器应回到闲置状态。
            break;
        default:
            break;
        }
    }

  用来测试的按钮依旧是手柄的□按键:ButtonA.Tick (Input.GetKey (keyButA));


  我的操作是快速按一下□(即按下就松开),可以看到计时器在我松开按钮之后马上计时,输出了181条信息,然后结束计时,根据Time.deltaTime(帧速一般情况下是1s60帧)和我给的时间范围3s,得出计时器输出的信息数目基本符合计算结果(3 * 60)。即计时器能正常工作,可喜可贺可喜可贺。
  接下来我在MyButton.cs里设置两个bool变量,这两个bool变量负责告知输入控制组件(JoystickInput.cs和PlayerInput.cs)计时器正在计时。

public class MyButton {
    ...
    public bool IsExtending = false;    //负责Double Trigger
    public bool IsDelaying = false;      //负责Long Press
    ...
}

  这两个变量会在计时器计时的期间为true,否则为false。

public void Tick(bool input){

        exitTimer.Tick ();
        delayTimer.Tick ();
        
        curState = input;
        IsPressing = curState;

        OnPressed = false;
        OnReleased = false;

        IsExtending = false;
        IsDelaying = false;

        if (curState != lastState) {
            if (curState == true) {
                OnPressed = true;
                StartTimer (delayTimer, 3.0f);
            } else {
                OnReleased = true;
                //StartTimer (delayTimer, 3.0f);
            }
        }
        if (exitTimer.state == MyTimer.STATE.RUN) {
            IsExtending = true;
        }
        if (delayTimer.state == MyTimer.STATE.RUN) {
            IsDelaying = true;
        }
        lastState = curState;
    }

  现在是时候对跑跳滚的条件判定进行大刀阔斧的更改了,我们原本的代码是:

//在JoystickInput.cs里
    void Update () {
        ...
        //角色奔跑
        run = Input.GetButton (keyButB);

        //角色跳跃
        jump = Input.GetButtonDown(keyButD);

        //角色攻击
        attack = Input.GetButtonDown(keyButLT);

        //角色举盾
        defense = Input.GetButton(keyButRB);
    }

  现在我们要把这几个功能按键的信号先送入键入复位反馈机制,让其拥有计时器,然后分析它们的条件判定:

//在JoystickInput.cs里
    private MyButton ButtonA = new MyButton ();
    private MyButton ButtonB = new MyButton ();
    private MyButton ButtonC = new MyButton ();
    private MyButton ButtonD = new MyButton ();
    private MyButton ButtonLT = new MyButton ();
    private MyButton ButtonRT = new MyButton ();
    private MyButton ButtonLB = new MyButton ();
    private MyButton ButtonRB = new MyButton ();
  
    void Update () {
        ButtonA.Tick (Input.GetKey (keyButA));
        ButtonB.Tick (Input.GetKey (keyButB));
        ButtonC.Tick (Input.GetKey (keyButC));
        ButtonD.Tick (Input.GetKey (keyButD));
        ButtonLT.Tick (Input.GetKey (keyButLT));
        ButtonRT.Tick (Input.GetKey (keyButRT));
        ButtonLB.Tick (Input.GetKey (keyButLB));
        ButtonRB.Tick (Input.GetKey (keyButRB));
        ...
    }

  在黑魂中实现跑跳滚功能的是○键,那么我认为在键入○键时间少于0.5s的且人物没有移动(移速低于某个值)的情况下是后跳,有移动就是翻滚;如果是大于0.5s就是奔跑。长按过后松开○键的0.15内如再键入○键就是跳跃+翻滚。
  对应的代码就是这样的:

        //角色奔跑
        run = (!ButtonC.IsDelaying && ButtonC.IsPressing) || ButtonC.IsExtending;

!ButtonC.IsDelaying:检测玩家按住○键是否超过0.5s,没超过为false,超过为true
ButtonC.IsPressing:如果已经超过0.5s(上一为true),这里检测玩家是否有在继续按住○,有就一直奔跑
ButtonC.IsExtending:如果玩家已经松开○,角色将继续进行0.15s的跑步再停止。

        //角色跳跃
        jump = ButtonC.IsExtending && ButtonC.OnPressed;

ButtonC.IsExtending:在松开○键后开始计时
ButtonC.OnPressed:如果在计时范围内再次键入○,就是为触发跳跃

        //角色攻击
        attack = ButtonRB.OnPressed;

        //角色举盾
        defense = ButtonLB.IsPressing;

  这两个不再赘述。下面重点讨论一下翻滚,之前我们触发翻滚动作的条件是:



  因为现在的跳跃有了更严格的限制条件,不是的单纯的接收○键的输入,所以jump已经不适于用来触发翻滚动作了,需要作出更改。在之前我们的动画状态机里就有一个roll的参数,我们可以用它来作为condition。



  但目前这个roll信号是用来检测是否触发落地翻滚的,如果落地速度超过某个阈值就触发:
        if (rigid.velocity.magnitude>7.0f) {
            anim.SetTrigger("roll");
        }

  当然现在仍适用,我们仍需要在落地很快的时候触发roll信号,但为了实现一般情况的翻滚,我们需要增加触发它的机会。为此我们要在IUserInput里增加一个bool变量roll:

    [Header("===== State =====")]
    public bool run;
    public bool jump;
    public bool attack;
    public bool defense;
    public bool rool;

  在JoystickInput.cs设置它的值:

        //角色翻滚
        roll = ButtonC.OnReleased && ButtonC.IsDelaying;

ButtonC.OnReleased && ButtonC.IsDelaying:检测玩家是否在蓄力计时阶段松开了按钮,如果是就视为玩家的意图是翻滚而不是奔跑。
  在ActorController.cs里修改触发roll参数(动画状态机的参数)的条件:

        if (pi.roll || rigid.velocity.magnitude>7.0f ) {
            anim.SetTrigger("roll");
        }

  然后把Conditions修改一哈:




  现在就可以来看看效果了,可以看到现在基本与黑魂的跳跃机制一致了


你可能感兴趣的:(Unity——#20 Double Trigger & Long Press)