Unity程序猿勇闯茶杯之魂(一)

阅读 209
收藏 5
2018-01-18
原文链接:zhuanlan.zhihu.com

本工程难度:★★☆☆☆

前言

12月8日,TGA 2017各项获奖名单公布,茶杯头(Cuphead)获得最佳艺术指导和最佳独立游戏两项大奖,可谓实至名归。

记得6月的时候我还在苦苦等待某狗头人的黑暗剑21(已经不存在了),在看E3发布会直播时,一款名为茶杯头的游戏立刻吸引了我,就冲这复古的画风我肯定买爆啊!待到发售日听闻好评一片而且难度极高,我心里暗自琢磨:哼,我可是黑魂三部曲总时间超过两百个小时,和历代薪王谈笑风生,什么大场面没见过,一个小小的茶杯头还想难倒我?拿起手柄开搞!然后我就被打脸了……

看到最多的就是这个画面

经历了重重磨难通关后,回头看了眼自己的死亡记录,emmmmm……

大概就是这样吧……

似乎找到了当初激情黑魂的感觉,茶杯之魂,名不虚传,在下佩服。

嘛,总之茶杯头是一款素质极佳的横板闯关游戏,在刚发售的那段时间里成为了各大游戏主播的新受苦素材,人气颇高。实际上,这款游戏就是用Unity制作的,这就引起了我的兴趣,作为一名Unity程序猿,肯定要尝试复刻一下啦。那么接下来,就开始我们的Cuphead之旅吧。本期先从主角茶杯头的基本操作开始介绍。

状态分析

对于角色的基本操作来说,如何处理好角色状态之间的切换是重中之重。茶杯头的教学关卡中几乎已经囊括了角色的所有状态,基本可以总结为下图中的几大类。在本期中仅介绍其中的一部分供大家参考。主要运用到的Unity模块为动画模块和2D物理模块。

角色状态一览

1.角色移动

首先角色的默认状态为站立闲置,当按下方向键(A或D)时向左/右移动,抬起时停止移动。同时需要注意,在处于下蹲状态时角色是不会移动的,所以在移动的函数中要先判断是否处于下蹲状态再根据情况改变角色速度。

private void _Move(int dic) //dic代表面向方向,左为-1,右为1
{
    if (!_isDown) //判断是否为下蹲状态
    {
        _rigidbody.velocity = new Vector2(dic * _moveSpeed, _rigidbody.velocity.y); //直接用rigidbody.velocity赋予角色速度
        _animator.SetBool("IsWalk", true); //触发行走动画
        _isWalking = true;
    }
    
    //当前进方向与面向方向不一致时反转角色
    if (dic > 0 && !_isFacingRight)
    {
        _Reverse();
    }
    else if (dic < 0 && _isFacingRight)
    {
        _Reverse();
    }
}

private void _StopMove() //停止移动
{
    //判断是否为冲刺状态,若在冲刺则保持速度不变
    if (!_isDash)
    { 
        _rigidbody.velocity = new Vector2(0, _rigidbody.velocity.y);
    }
    else
    { 
        _rigidbody.velocity = new Vector2(_rigidbody.velocity.x, _rigidbody.velocity.y);
    }

    _animator.SetBool("IsWalk", false);
    _isWalking = false;
}

角色移动效果如下图(一个完整的左脚换右脚再换回左脚总共是16张图片组成的帧动画,抠图抠的爽歪歪):


2.角色跳跃

角色跳跃直接改变角色在Y轴方向上的速度,这里的重点在于站立动画和跳跃动画的切换。我在这里采用的方法是在角色脚下放置监测点,通过向下发射射线检测的方式确认角色是否接触地面并控制动画状态机的相关变量,射线检测的距离可以按照实际需要调整。

//Update函数中的相关代码
if (!_isGrounded && !_isJump)
{
    _animator.SetBool("IsJump", true);
    _isJump = true;
}
else if(_isGrounded)
{
    _animator.SetBool("IsJump", false);
    _isJump = false;

    if (!_isAlreadyLand)
    {
        GroundDust(); //产生落地的灰尘
        _isAlreadyLand = true;
    }
}
//End Update

private void _Jump() //角色跳跃
{
    _rigidbody.velocity = new Vector2(_rigidbody.velocity.x, _jumpSpeed);
}

跳跃效果如下(特地看了一下原版游戏,一次完整的跳跃大概是跳跃动画循环两次,哇,我是有多无聊……):

3.角色下蹲

下蹲也是检测按钮状态,按下或按住则蹲下,抬起则恢复到站立状态。这里的关键点在于如果处于跳跃状态一定要将下蹲状态关闭,否则在下蹲时按下跳跃按钮并在跳跃状态中放开下蹲按钮,当角色落地时就会一直处于下蹲状态(嗯,bug就是这么产生的……)

private void _Down(bool isDown)
{
    _animator.SetBool("IsDown", isDown);
    _isDown = isDown;

    if(_isJump) //跳跃时下蹲无效
    {
        return;
    }

    if(isDown) //下蹲时停止移动
    {
        _StopMove();
    }
}

效果如下(看我上下鬼畜!):

4.角色冲刺

角色冲刺时要注意是不受重力影响的,所以在角色处于冲刺状态时将其重力影响设置为0,冲刺结束后再设置回来;并且还要考虑在空中的特殊情况,如果在空中已经冲刺则需要禁止再次触发冲刺状态,以免出现在空中多次冲刺的尴尬场面……

//Update中的相关代码
if(_isDash && (_stateInfo.IsName("DashGround") || _stateInfo.IsName("DashAir")))
{
    _rigidbody.velocity = new Vector2(_faceDic * _dashSpeed, 0);

    if(_stateInfo.normalizedTime > 0.9)
    {
        _rigidbody.velocity = new Vector2(0, 0);
        _rigidbody.gravityScale = 6;
        _isDash = false;

        _animator.SetTrigger("QuitDash");       
    }
}
//End Update

private void _Dash()
{
    if(_isAlreadyAirDash) //如果在空中已经冲刺则返回
    {
        return;
    }

    if(!_isGrounded) //在地面和空中分别触发不同的动画
    {
        _rigidbody.gravityScale = 0;
        _animator.SetTrigger("AirDash");
        _isAirDash = true;

        _isAlreadyAirDash = true;
    }
    else
    {
        _animator.SetTrigger("GroundDash");
    }

    _isDash = true;
}

效果如下(请自行脑补xiu~xiu~xiu~的声音):

5.角色射击

射击状态是条件限制最多的状态,需要考虑的情况较多。首先,原地站立时按下上键会向上射击。行走时按下射击键会切换到行走射击动画,这里需要运用到blend tree,如果仅按下射击键则将Thresh值设置到向正前方射击的区域;如果还按下了上键,需要将Thresh值设置到斜上方射击的区域;如果放开射击键,还需要将Thresh值设置到初始未射击的区域。如果处于跳跃状态,仅可向正前方和下方发射子弹。好吧,说的我自己都晕了,但这里一定要注意这些细节的处理。

private void _Shoot()
{
    _shootDic = new Vector2(_faceDic, 0); //射击方向,为后面的子弹运动做准备
    _bulletBornPos = _standBulletPos; //子弹产生点

    if (_isWalking && !_isJump) //在地面行走时射击
    {
        if(Input.GetKeyDown(KeyCode.W) || Input.GetKey(KeyCode.W)) //若按下向上按钮,动画变为向斜上方射击的同时步行
        {
            _animator.SetFloat("WalkState", 1f);
            _shootDic = new Vector2(_faceDic, 1).normalized;
            _bulletBornPos = _walkRightUpBulletPos;
        }
        else
        {
            _animator.SetFloat("WalkState", 0.333f);
            _bulletBornPos = _walkRightBulletPos;
        }

        _timeCount += Time.deltaTime;

        if (_timeCount >= 0.0165 * 9) //设定行走射击的时间间隔
        {
            _timeCount = 0f;
            WalkShoot();
        }
    }
    else if(!_isWalking && !_isJump) //原地站立时射击
    {
        _animator.SetBool("IsShoot", true);

        if (Input.GetKeyDown(KeyCode.W) || Input.GetKey(KeyCode.W)) //若按下向上按钮则播放向上射击动画
        {
            _animator.SetFloat("IdleState", 1f);
            _shootDic = new Vector2(0, 1).normalized;
            _bulletBornPos = _standUpBulletPos;
        }
        else
        {
            _animator.SetFloat("IdleState", 0f);
            _bulletBornPos = _standBulletPos;
        }
    }
    //蹲下及跳跃时射击请参考源代码
    _isShoot = true;
}

运行效果如下(吃我一发空气弹!):

让子弹飞

角色的基本操作暂时先介绍这么多,接下来要介绍子弹运动及动画效果该如何实现。

首先要做的,是要确定子弹的产生位置,为此,需要在角色身上按照不同的射击动画时手指的位置设置好子弹诞生点:

角色身上挂载的子弹产生点

接下来就是该在何时产生子弹,如果是站立或下蹲时射击,则在射击动画的第一帧添加发射子弹的帧事件;若是在行走或跳跃时射击,则按照一定的时间间隔产生子弹。同时在玩家的相关代码中要将射击子弹的消息传递给子弹管理器,这里我采用的是事件传递的方式:

public event Action<Vector2, Vector2> OnShoot; //站立射击实践
public event Action<Vector2, Vector2, Transform> OnWalkShoot; //行走射击事件

public void ShootBullet() //站立射击的帧事件
{
    Vector2 bornPos = _bulletBornPos.position;

    if (OnShoot != null)
    {
        OnShoot(bornPos, _shootDic);
    }
}

//行走射击时需要再传一个子弹产生点的transform参数,其余和站立射击相似

我们还需要一个子弹管理器来生成子弹,将玩家的射击事件传入并在特定位置创建子弹,同时,我们也可以在这个脚本中管理子弹生成时的特效。这里需要注意的是在行走时射击的话,需要将子弹生成特效的父节点设置为子弹生成点,这样的话,biubiubiu特效就能跟着手指走啦;还需要特别指出一点,向正前方射击时每一颗子弹并不是在同一水平面上的,也就是说,子弹要有一定的垂直偏移,这样才有一种子弹忽上忽下的视觉效果,在代码中就需要一个列表来存储子弹的垂直偏移量,通过循环获取列表的方式改变子弹位置的Y值:

private void _OnShoot(Vector2 bornPos, Vector2 shootDic) //站立射击
{
    var born = _GetBornInstance();
    born.transform.position = new Vector3(bornPos.x, bornPos.y, -1);

    var bullet = _GetBulletInstance();
    bullet.transform.position = _GetOffsetPos(bornPos);
    bullet.GetComponent<Bullet>().StartMove(shootDic);
}

//行走射击时需要设置子弹生成特效的父节点,其余和站立射击相似

private Vector3 _GetOffsetPos(Vector2 pos) //获取子弹位置的偏移量
{
    var offsetY = pos.y + _shootOffsets[_curOffsetIndex];

    if(_curOffsetIndex < 2)
    {
        _curOffsetIndex += 1;
    }
    else
    {
        _curOffsetIndex = 0;
    }

    return new Vector3(pos.x, offsetY, -1);
}

子弹的运动直接通过改变transform.position实现,这里的重点之一是要将子弹旋转至前进方向,所以在开始运动前要先运用四元旋转改变子弹的rotation值;第二点要注意的是,如果子弹接触到地面或者敌人要有对应的反馈(也就是击中时的帧动画),这里我直接在子弹的预制体上加了trigger,运用trigger enter来判断是否要触发相应动画:

public void StartMove(Vector2 shootDir) //开始运动
{
    _isMoving = true;
    _moveDir = new Vector3(shootDir.x, shootDir.y, 0).normalized;
    var origin = new Vector3(1, 0, 0).normalized;
    var rotate = Quaternion.FromToRotation(origin, _moveDir); //根据子弹前进方向对其旋转
    this.transform.rotation = rotate;
}

private void _Destroy() //子弹的销毁
{
    Destroy(this.gameObject);
    _isMoving = false;
}

private void OnTriggerEnter2D(Collider2D collision)
{
    if (collision.gameObject.layer == _groundLayer.value) //子弹触碰到地面时触发子弹击中动画
    {
        _isMoving = false;
        _animator.SetTrigger("Hit");
    }
}

其实,这种直接销毁子弹的方法消耗很大,若想获得更好的运行效率,需要运用到对象池,关于对象池,大家可以参考这篇文章【Unity】工具类系列教程——对象池!

一切准备就绪,那么就让子弹飞起来吧:

And More

在茶杯头中,角色行走时,冲刺时,落地时均会产生灰尘溅散的效果,满满的都是细节。那么为了表现这些效果,我们同样要先确定好灰尘生成的位置并在玩家脚本中运用事件触发。接着和子弹一样再创建一个灰尘管理器,这里也有个细节需要注意,游戏中行走时的灰尘是有多个种类的(只能佩服制作组的良心),这里我只准备了三个预制体,在每次生成行走灰尘时要随机选取其中的一个:

private GameObject _GetDustInstance(DustType rType) //根据灰尘的种类获取对应的灰尘预制体
{
    switch(rType)
    {
        case DustType.WalkDust:
            int index = Random.Range(0, 3); //获取随机数以便随机获取行走灰尘
            return Instantiate(_dustPrefabList[index]);
        case DustType.DashDust:
            return Instantiate(_dashDustPrefab);
        case DustType.GroundDust:
            return Instantiate(_groundDustPrefab);
    }
    return null;
}

private void _CreateDust(Vector2 pos, DustType rType)
{
    var instance = _GetDustInstance(rType);
    instance.transform.position = new Vector3(pos.x, pos.y, -2);
}

最终的效果就像下面这样啦(突然就酷炫了起来):

那么本期就介绍到这里了,完整代码请移步Yukimine33/CupheadCodeByMyself。其实角色逻辑并不难,但要有足够的耐心去调整细节,还要把大量的精力放在裁剪图片及制作帧动画上,可想而知制作组花费了多少心血才能给玩家带来这样一部作品,也希望大家去多多支持这样的良心作品(购买链接: Cuphead on Steam)。下一期将补全角色的基本操作并开始Boss的制作,敬请期待(内心os:还有好多图要裁啊,还有好多代码要调整修改啊,也许摸了~)

噢,对了,有想学习游戏开发的同学,可移步至www.levelpp.com围观一波哟。

评论