• 尝试用Unity还原蔚蓝(Celeste)—— 真·操控、移动、手感篇


    一、准备阶段
    本次项目需要一个完全不能穿墙,并且能够精确操控的移动方式,所以用AddForce,或者velocity等方法是很难精确控制角色,并且有概率穿墙。全程用 transform相关的移动方式又需要做太多的判断,比较难写,而且容易出问题,所以决定使用Rigidbody.MovePosition(Vector2 position)来进行移动,同时修改Rigidbody2D的Collision Detection为Continuous防止高速移动时角色穿墙,用Composite Collider2D将Tilemap合并,使碰撞盒更加平滑。顺便一提,现在黑色主题已经对所有用户开放,大家还不赶紧去试试?

     

    主要用状态机的方式来控制角色在各种状态间的切换,另外为了控制篇幅,本文大部分代码都不是完整代码,完整代码详见文末链接。


    二、输入控制
    1、左右按键输入优化
    我们知道通过input.getaxis可以模拟摇杆的输入,输入会从0到±1之间变化,就能模拟移动和停止惯性。但是我们却不能准确的操控惯性的大小,所以加速和减速需要重写代码控制。具体方法详见3.1,这里输入只需要返回0,±1即可。所以重点就是需要什么时候返回这三个值。

    通常是按下右键返回1,按下左键返回-1,但如果左右键一起按应该返回什么?在unity中一起按是返回0,而在蔚蓝中,总是返回后输入的按键。所以在这里需要手动判断返回的值。

      void CheckHorzontalMove()

        {

                   //<= 0说明按下左键或者没有案件,判断这时是否按下了右键

                  if (Input.GetKeyDown(RightMoveKey) && Input.GetAxisRaw("Horizontal")<= 0)

                  {

                         MoveDir = 1;

                  }

                  //...同理左键判断

                  else if (Input.GetKeyUp(RightMoveKey))  //判断放开右键时检测左键是否按下中

                  {

                         if (Input.GetKey(LeftMoveKey))  //放开右键的时候仍按着左键

                         {

                                MoveDir = -1;

                                MoveStartTime = Time.time;

                         }

                         else

                         {

                                MoveDir = 0;

                         }

                  }

                  //...同理左键判断

           }

    2、跳跃输入优化
    如果在落地前几帧之内按下跳跃键,游戏应该要记住指令使角色仍能在落地后跳跃,这会让角色跳跃的更加顺滑。为了精确到帧的控制,可以通过把一个int变量放在FixedUpdate里每帧减1来控制。在按下跳跃键时开启一个帧数倒计时,如果落地后该int变量还不为0则仍然返回true,即按下跳跃按键。

      private void FixedUpdate()

        {

            if(JumpFrame >= 0)

            {

                JumpFrame--;

            }

        }

       public bool JumpKeyDown {

            get

            {

                if(Input.GetKeyDown(Jump))

                {

                  return true;

                }

                else if(JumpFrame > 0)

                {

                  return true;

                }

                return false;

            }

        }


    三、移动
    主要移动代码为如下,velocity的值就相当于角色的速度。

    rig.MovePosition(transform.position + Velocity * Time.fixedDeltaTime);

    1、水平移动
    从上文中提到的视频中可知,角色是6帧达到满速,然后只用3帧就可以停下,但是这里的1帧是多少秒呢?

    我尝试用1秒30帧,即0.2s到达满速,0.1s停止测试了下手感,发现有明显的惯性,所以猜测原视频是以1秒60帧为基础。为了尽可能还原,我将FixedUpdate的刷新频率也设置成了1秒60次。

    实现的方式也很简单,MoveSpeed 为最大速度,在FixedUpdate中,加速时Velocity += MoveSpeed /6, 减速时Velocity.x-= MoveSpeed /3。要注意方向,方向相反正负号也要跟着变。

    除此之外还有一些细节,角色在空中加速和减速的时间都是比在陆地上的慢。蹲下时是不允许左右移动,陆地上移动时按下下键,也是会进入蹲的状态不允许移动。都是一些判断,再次就不详细赘述了。

    2、落下状态
    由于没有用到unity自带的物理引擎,所以需要模拟重力。以自身碰撞盒为大小向下打射线,同时忽略玩家层。当然为了以后的爬墙、手感优化等,自身四个方向上都需要打射箭进行检测。

    playerLayerMask = ~LayerMask.GetMask("Player"); //忽略玩家层

     DownBox = Physics2D.BoxCast(Position, boxSize, 0, Vector3.down,

     0.05f, playerLayerMask);

    当角色处于可落下的情况,并且射线检测不到地面时开始添加重力,设置一个最大坠落速度

    Velocity.y -= 150f * Time.deltaTime;

    Velocity.y = Mathf.Clamp(Velocity.y, -25, Velocity.y);

    3、爬墙状态
    爬墙有两种情况,一种是有耐力,并且按下爬墙键会紧贴在墙上,如果按下墙壁方向的方向键,同时不按爬墙键或者没耐力了就会沿着墙壁下滑。

    实现也很简单,将我上面描述的转换成if语句就行,由于在墙上不会移动x轴,所以移动是修改velocity.y来实现。详情可参考完整工程,这里就提一下爬墙的两个细节。

    1、如果在墙的边缘垂直起跳,从进入爬墙状态起会滑落一段距离。

    2、如果在墙的边缘按上键,会自动将玩家移动到平台上。

    1的方法是获取自身位置与碰撞点y轴的差距,如果大于一定值就滑落,这里我忽略了耐力。

    2的方法同样是获取获取自身位置与碰撞点y轴的差距,如果小于一定值并且按了上键就自动跳到平台上,自动跳跃我用协程的方式实现。

           void Climb()

    {

                if (UpBox.Length == 0)

                {

                    if (input.v > 0 && transform.position.y - HorizontalBox[0].point.y > 0.9f)

                    {

                        StartCoroutine("ClambAutoJump");

                        return;

                    }

                }

                //如果爬在墙的最上端要么自动跳到平台上,要么滑落一段距离

                if (input.v <= 0 && transform.position.y - HorizontalBox[0].point.y > 0.7f

                      || !input.ClimbKey)

                {

                    Velocity.y = -ClimbSpeed;              //向下滑落

                }

                else if (transform.position.y - HorizontalBox[0].point.y <= 0.7f || input.ClimbKey)

                {

                    Velocity.y = input.v * ClimbSpeed;     //根据输入判断是否移动

                }

    }

        // 攀爬到墙壁最上沿时如果有可跳跃平台,则自动跳跃到平台上

        IEnumerator ClambAutoJump()

        {

            var posY = Mathf.Ceil(transform.position.y);

            isCanControl = false;

            Velocity = Vector3.zero;

            while (posY + 1f - transform.position.y > 0)

            {

                Velocity.y = JumpSpeed;

                Velocity.x = GetDirInt * 15;

                yield return null;

            }

            Velocity = Vector3.zero;

            playState = PlayState.Fall;

            isCanControl = true;

        }

    4、冲刺状态
    因为跳跃是本游戏最重要的部分,所以先来介绍一下冲刺。

    在蔚蓝中,冲刺的前0.15s是不允许移动的,所以冲刺也是通过协程来实现。

    在冲刺前我们要获取到冲刺方向,这里有几个小细节,什么都不按的情况下按冲刺是以角色当前方向进行冲刺,在地面上按左下或者右下冲刺时是普通的横向冲刺,

    //获取输入时的按键方向

           float verticalDir;

           if(isGround && input.v < 0)  //在地面上并且按住下时不应该有垂直方向

           {

                  verticalDir = 0;

           }

           else

           {

                  verticalDir = input.v;

           }

            //冲刺方向注意归一化

            DashDir = new Vector2(input.MoveDir, verticalDir).normalized;

            if(DashDir == Vector2.zero)   //此处为0说明只按下了冲刺键。

            {

                    //没有按方向键就朝玩家正前方冲刺     

                  DashDir = Vector3.right * GetDirInt;

            }

    用Time.time来控制时间是很不准确的,会出现冲刺距离时远时近。所以在协程里想要精确到帧的控制可以使用WaitForFixedUpdate。0.15s在1秒60帧的情况下就是9帧。

            isCanControl = false;  //不允许操控

            while (i < 9) 

            {

                   if(playState == PlayState.Dash)

                  {

                         Velocity = DashDir * 30f;

                  }

                  i++;

                  CheckHorizontalMove();   //横向移动优化,见下文

                  yield return new WaitForFixedUpdate();

            }

            isCanControl = true;

    虽然在冲刺的0.15s中不允许操控,但有些情况下是可以跳跃的,所以会切换到跳跃状态,这时不能停止冲刺的协程,因为还要等循环结束把操作权还给玩家,所以在代码中加了一个判断,当前在冲刺状态时才给角色冲刺速度。

    移动优化:

    冲刺的时候为了使移动看起来更加顺滑,会在碰到各种碰撞的边缘处进行处理,看一下动图就明白了。

    当角色在移动中碰到碰撞体的边缘时应该要像果冻一样划过去。当然不只是在冲刺中会用到,跳跃时也会用到。实现原理:当前玩家位置与碰撞点位置之差来判断是否应该对位移进行修正。图中黄线的一端就是上次碰撞点的位置。由于地图使用tilemap进行绘制,所以瓦片的位置都是能够确定,在此基础上进行修正。

    //检测正上方的唯一修正

    bool CheckUpMove()

           {

                  if (UpBox.Length == 1)

                  {

                            //获取自身与碰撞点的x轴距离

                         var pointDis = UpBox[0].point.x - transform.position.x;

                         if (pointDis > 0.34f)  //如果在平台左侧边缘

                         {

                                var offsetPos = Mathf.Floor(transform.position.x); //唯一修正

                                transform.position = new Vector3(offsetPos + 0.48f, transform.position.y, 0);

                                return true;

                         }

                         else if (pointDis < -0.34f)

                         {

                                var offsetPos = Mathf.Floor(transform.position.x);

                                transform.position = new Vector3(offsetPos + 0.52f, transform.position.y, 0);

                                return true;

                         }

                         else  //其他情况就是碰撞到墙壁,切换为落下状态

                         {

                                Velocity.y = 0;

                                playState = PlayState.Fall;

                                return false;

                         }

                  }

                  return true;

           }

      //检测水平方向位移

    void CheckHorizontalMove()

           {

                  HorizontalBox = playDir == PlayDir.Right ? RightBox : LeftBox;

                  if (HorizontalBox.Length == 1)

                  {

                         var pointDis = HorizontalBox[0].point.y - Position.y;

                         Debug.LogError(pointDis);

                         if (pointDis > 0.34f)

                         {

                                var offsetPos = Mathf.Ceil(Position.y);

                                transform.position = new Vector3(transform.position.x, offsetPos - 0.22f, 0);

                         }

                         else if (pointDis < -0.42f)

                         {

                                var offsetPos = Mathf.Ceil(transform.position.y);

                                transform.position = new Vector3(transform.position.x, offsetPos + 0.035f, 0);

                         }

                  }

           }

    5、跳跃
    跳跃是蔚蓝的灵魂,也是全篇最复杂的部分之一,这里我略微的参考了游戏源代码。

    蔚蓝中跳跃是会有一个最小跳跃高度和最大跳跃高度,并且跳跃分成了三个阶段:

    1、加速上升阶段

    2、到达最小跳跃高度后如果仍然按主跳跃键则继续升高阶段

    3、到达最高点或者放开跳跃键后的减速上升阶段,上升速度为0时切换到落下状态。

    跳跃也是用协程进行实现,首先来看第一个阶段。vel.x为传入的横向加速度,maxVel.x为最大横向加速度,在下文蹬墙跳中实现横向加速跳跃。

    while (playState == PlayState.Jump && dis <= curJumpMin && Velocity.y < curJumpSpeed)

            {

                    //获取当前角色相对于初始跳跃时的高度

                  dis = transform.position.y - startJumpPos; 

                    //vel.x为横向加速度,是为了在蹬墙跳的时候提供一个横向的加速移动效果

                  if (vel.x != 0 && Mathf.Abs(Velocity.x) < maxVel.x)

                  {

                         isMove = false;   //在蹬墙跳跃中是不允许操控横向位移的。

                         Velocity.x += vel.x;

                         if(Mathf.Abs(Velocity.x) > maxVel.x)

                          {

                                Velocity.x = maxVel.x * GetDirInt;

                         }

                  }

                  if (!CheckUpMove())  //正上方移动优化检测,返回false说明撞到墙,结束跳跃

                {

                    Velocity.y = 0;

                    isIntroJump = false;

                  isMove = true;

                  yield break;

                }

                  if (vel.y <= 0)

                  {

                         Velocity.y += 240 * Time.fixedDeltaTime;  //加速移动

                  }

                  yield return new WaitForFixedUpdate();

            }

    在这段代码中加入了蹬墙跳跃的方法,这里就一并说完。

     

    再上图中我们可以看到4种在墙上的跳跃方式,

    1、按住爬墙键并且有耐力的情况下只按跳跃键会垂直向上跳。

    2、在上一条的情况下按反方向键和跳跃是蹬墙跳。

    3、处于在墙壁上滑落状态时按下跳跃键也是相同效果。滑落状态也分两种,一是按住爬墙键但是没有耐力,二是不按爬墙键时,而是按下墙所在方向的方向按键。

    4、在空中靠近墙壁时,只按下跳跃键,会有一个比蹬墙跳稍近一些的蹬墙跳。顺便一提,这种方式跳跃不会消耗体力,所以蔚蓝中有个高级技巧,无(消耗)体力上墙就是通过这种方式实现。

    //蹬墙跳,这里暂时忽略了耐力

    //在爬爬墙状态下,按下跳跃键

           if(input.JumpKeyDown)

           {

                  if(input.ClimbKey)

                  {

                            //第二种情况

                         if((input.h > 0 && GetDirInt < 0) || (input.h < 0 && GetDirInt > 0))

                         {

                                Jump(new Vector2(8 * -GetDirInt, 0), new Vector2(24 , 0));

                         }

                         else

                         {

                                Jump();  //第一种情况

                         }

                  }

                  else

                  {

                            //第三种情况

                         Jump(new Vector2(8 * -GetDirInt, 0), new Vector2(24 , 0));

                  }

           }

    第四种情况只会在落下或者跳跃时触发,将修改下数值调用即可

    Jump(newVector2(4 * -GetDirInt,0),newVector2(18,0));

    GIF

    第二阶段,如果仍然按下跳跃键,并且跳跃高度小于最大跳跃高度时。

           while (playState == PlayState.Jump && input.JumpKey && dis < curJumpMax)

            {

                  if (!CheckUpMove())  //正上方移动优化检测,返回false说明撞到墙,结束跳跃

                {

                    Velocity.y = 0;

                    isIntroJump = false;

                         yield break;

                }

                    //上文所说的第四种蹬墙跳检测

                  if (input.JumpKeyDown && BoxCheckCanClimb() && !input.ClimbKey && !CheckIsClimb())

                  {

                         Velocity.y = 0;

                         isIntroJump = false;

                         Jump(new Vector2(4 * -GetDirInt, 0), new Vector2(18 , 0));

                         yield break;

                  }

                   dis = transform.position.y - startJumpPos;

                    Velocity.y = curJumpSpeed;

                    yield return new WaitForFixedUpdate();

            }

    在这个阶段中添加了对蹬墙跳的检测,此时y轴方向的速度仍然是跳跃速度。

    第三阶段,减速直到向上的速度为0。

           // slow down

                  while (playState == PlayState.Jump && Velocity.y > 0 )

                {

                  if (!CheckUpMove())

                {

                    break;

                }

                  if (input.JumpKeyDown && BoxCheckCanClimb() && !input.ClimbKey && !CheckIsClimb())

                  {

                         Velocity.y = 0;

                         isIntroJump = false;

                         Jump(new Vector2(4 * -GetDirInt, 0), new Vector2(24, 0));

                         yield break;

                  }

                //如果跳跃到最大高度是重力是其他情况的一半

               if (dis > JumpMax)

                {

                    Velocity.y -= 100 * Time.fixedDeltaTime;

                }

                else

                {

                    Velocity.y -= 200 * Time.fixedDeltaTime;

                }

                yield return new WaitForFixedUpdate();

                }

    减速阶段也有一个小细节,跳跃到最高处时重力会减半,这会使玩家更加方便的操控角色。但是由于这段时间比较短,gif并不能展示出比较好的效果,这里就不做展示。


    冲刺跳跃
    只有以上部分是不足以完全还原蔚蓝的跳跃,因为在游戏中玩家的跳跃是会叠加的,也就是说如果在冲刺的时候按下跳跃键,跳跃时是会叠加冲刺时的速度。横向上比较简单,垂直方向上我们也只需要把最小跳跃高度和最大跳跃高度计算一下即可。

           float curJumpMin = JumpMin * (vel.y + JumpSpeed)/ JumpSpeed;

           float curJumpMax = JumpMax * (vel.y + JumpSpeed) / JumpSpeed;

           float curJumpSpeed = JumpSpeed + vel.y;

    最后就是各种冲刺和跳跃的叠加操作了,充能大跳、低空充能大跳、蹭墙跳(7B技巧)、“凌波微步”等操作技巧最多就是修改下传入参数即可。

    GIF

    在蹭墙跳跃中还有一个细节,因为原本就是一个比较难的操作,所以制作人为了优化将此时的墙壁检测放大了一下,即平常离墙壁比较远不能蹬墙跳的位置,蹭墙跳可以触发。

    土狼跳(Coyotetime)
    最后就用一个在平台跳跃游戏里比较常见的细节来结束吧,在人物离开平台的后几帧时间里,玩家仍然能跳跃,当然实现方式也非常简单,在从普通移动状态切换到落下状态时记录一个int变量,同样的在FixedUpdate每次减1来控制在几帧内仍可以跳跃。只需要注意此时要把垂直方向上的速度清零。

    代码就不展示了,直接上效果, 为了展示的更加清楚我将这个时间设置的比较长,蔚蓝中设置的是3帧。

     

    三、结语
    当然游戏细节尚不止如此,还有许多诸如冲刺时碰撞体缩小,在空中按住下加速下落,关于刺的细节等等。引用上一篇评论,那5000行代码标志着目前横版跳跃类游戏的天花板。我也只能尽力模仿,努力学习。

    内容不多,但是为了实现反反复复的修改了很多遍,而且由于蔚蓝源码所使用的像素比例和本项目中用的不一致,所以各种参数也不能完全参考,修改参数也是修改的我心力交瘁。最后虽然是完成了一部分的移动和手感,但离我理想仍有不少的差距。接下来的一篇应该就是,动画、特效、镜头添加,死亡状态添加,现有的移动继续完善等,不过这部分就比较简单,应该会比较快吧。

    项目链接:https://pan.baidu.com/share/init?surl=76fnkQOSnp--SAPbTf2uFA

    提取码:23i8 

     

  • 相关阅读:
    【高质量C/C++】4.表达式和基本语句
    MAUI+Blazor混合应用开发示例
    alertmanager集群莫名发送resolve消息的问题探究
    如何开始做股票量化交易?
    Cobalt Strike基本使用
    ES6中的set与map
    5种排序算法
    Python 爬虫实战
    Java项目:JSP手机商城管理系统包含前台
    基于微信小程序的机房设备故障报修平台
  • 原文地址:https://blog.csdn.net/m0_69824302/article/details/127994472