Unity——#20 Double Trigger & Long
这节我们打算真正实现黑魂里面的跳跃机制,我先讲述的黑魂是怎么跳的。在黑魂中,它把后跳、翻滚、前跳、跑步做在了同一个按键(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.Engine
的Time.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)。
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修改一哈:
现在就可以来看看效果了,可以看到现在基本与黑魂的跳跃机制一致了