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

作者:Yukine
本工程難度:★★☆☆☆
前言
《茶杯頭》(Cuphead)這個(gè)游戲,獲得TGA 2017最佳藝術(shù)指導(dǎo)和最佳獨(dú)立游戲兩項(xiàng)大獎(jiǎng),可謂實(shí)至名歸。
記得去年6月的時(shí)候我還在苦苦等待某狗頭人的黑暗劍21(已經(jīng)不存在了),在看E3發(fā)布會(huì)直播時(shí),一款名為茶杯頭的游戲立刻吸引了我,就沖這復(fù)古的畫(huà)風(fēng)我肯定買(mǎi)爆?。〈桨l(fā)售日聽(tīng)聞好評(píng)一片而且難度極高,我心里暗自琢磨:哼,我可是黑魂三部曲總時(shí)間超過(guò)兩百個(gè)小時(shí),和歷代薪王談笑風(fēng)生,什么大場(chǎng)面沒(méi)見(jiàn)過(guò),一個(gè)小小的茶杯頭還想難倒我?拿起手柄開(kāi)搞!然后我就被打臉了……

經(jīng)歷了重重磨難通關(guān)后,回頭看了眼自己的死亡記錄,emmmmm……

似乎找到了當(dāng)初激情黑魂的感覺(jué),茶杯之魂,名不虛傳,在下佩服。

嘛,總之茶杯頭是一款素質(zhì)極佳的橫板闖關(guān)游戲,在剛發(fā)售的那段時(shí)間里成為了各大游戲主播的新受苦素材,人氣頗高。實(shí)際上,這款游戲就是用Unity制作的,這就引起了我的興趣,作為一名Unity程序猿,肯定要嘗試復(fù)刻一下啦。那么接下來(lái),就開(kāi)始我們的Cuphead之旅吧。本期先從主角茶杯頭的基本操作開(kāi)始介紹。
狀態(tài)分析
對(duì)于角色的基本操作來(lái)說(shuō),如何處理好角色狀態(tài)之間的切換是重中之重。茶杯頭的教學(xué)關(guān)卡中幾乎已經(jīng)囊括了角色的所有狀態(tài),基本可以總結(jié)為下圖中的幾大類(lèi)。在本期中僅介紹其中的一部分供大家參考。主要運(yùn)用到的Unity模塊為動(dòng)畫(huà)模塊和2D物理模塊。

1.角色移動(dòng)
首先角色的默認(rèn)狀態(tài)為站立閑置,當(dāng)按下方向鍵(A或D)時(shí)向左/右移動(dòng),抬起時(shí)停止移動(dòng)。同時(shí)需要注意,在處于下蹲狀態(tài)時(shí)角色是不會(huì)移動(dòng)的,所以在移動(dòng)的函數(shù)中要先判斷是否處于下蹲狀態(tài)再根據(jù)情況改變角色速度。
private void _Move(int dic) //dic代表面向方向,左為-1,右為1
{
if (!_isDown) //判斷是否為下蹲狀態(tài)
{
_rigidbody.velocity = new Vector2(dic * _moveSpeed, _rigidbody.velocity.y); //直接用rigidbody.velocity賦予角色速度
_animator.SetBool("IsWalk", true); //觸發(fā)行走動(dòng)畫(huà)
_isWalking = true;
}
//當(dāng)前進(jìn)方向與面向方向不一致時(shí)反轉(zhuǎn)角色
if (dic > 0 && !_isFacingRight)
{
_Reverse();
}
else if (dic < 0 && _isFacingRight)
{
_Reverse();
}
}
private void _StopMove() //停止移動(dòng)
{
//判斷是否為沖刺狀態(tài),若在沖刺則保持速度不變
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;
}
角色移動(dòng)效果如下圖(一個(gè)完整的左腳換右腳再換回左腳總共是16張圖片組成的幀動(dòng)畫(huà),摳圖摳的爽歪歪):

2.角色跳躍
角色跳躍直接改變角色在Y軸方向上的速度,這里的重點(diǎn)在于站立動(dòng)畫(huà)和跳躍動(dòng)畫(huà)的切換。我在這里采用的方法是在角色腳下放置監(jiān)測(cè)點(diǎn),通過(guò)向下發(fā)射射線檢測(cè)的方式確認(rèn)角色是否接觸地面并控制動(dòng)畫(huà)狀態(tài)機(jī)的相關(guān)變量,射線檢測(cè)的距離可以按照實(shí)際需要調(diào)整。
//Update函數(shù)中的相關(guān)代碼
if (!_isGrounded && !_isJump)
{
_animator.SetBool("IsJump", true);
_isJump = true;
}
else if(_isGrounded)
{
_animator.SetBool("IsJump", false);
_isJump = false;
if (!_isAlreadyLand)
{
GroundDust(); //產(chǎn)生落地的灰塵
_isAlreadyLand = true;
}
}
//End Update
private void _Jump() //角色跳躍
{
_rigidbody.velocity = new Vector2(_rigidbody.velocity.x, _jumpSpeed);
}
跳躍效果如下(特地看了一下原版游戲,一次完整的跳躍大概是跳躍動(dòng)畫(huà)循環(huán)兩次,哇,我是有多無(wú)聊……):

3.角色下蹲
下蹲也是檢測(cè)按鈕狀態(tài),按下或按住則蹲下,抬起則恢復(fù)到站立狀態(tài)。這里的關(guān)鍵點(diǎn)在于如果處于跳躍狀態(tài)一定要將下蹲狀態(tài)關(guān)閉,否則在下蹲時(shí)按下跳躍按鈕并在跳躍狀態(tài)中放開(kāi)下蹲按鈕,當(dāng)角色落地時(shí)就會(huì)一直處于下蹲狀態(tài)(嗯,bug就是這么產(chǎn)生的……)
private void _Down(bool isDown)
{
_animator.SetBool("IsDown", isDown);
_isDown = isDown;
if(_isJump) //跳躍時(shí)下蹲無(wú)效
{
return;
}
if(isDown) //下蹲時(shí)停止移動(dòng)
{
_StopMove();
}
}
效果如下(看我上下鬼畜?。?/p>
4.角色沖刺
角色沖刺時(shí)要注意是不受重力影響的,所以在角色處于沖刺狀態(tài)時(shí)將其重力影響設(shè)置為0,沖刺結(jié)束后再設(shè)置回來(lái);并且還要考慮在空中的特殊情況,如果在空中已經(jīng)沖刺則需要禁止再次觸發(fā)沖刺狀態(tài),以免出現(xiàn)在空中多次沖刺的尷尬場(chǎng)面……
//Update中的相關(guān)代碼
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) //如果在空中已經(jīng)沖刺則返回
{
return;
}
if(!_isGrounded) //在地面和空中分別觸發(fā)不同的動(dòng)畫(huà)
{
_rigidbody.gravityScale = 0;
_animator.SetTrigger("AirDash");
_isAirDash = true;
_isAlreadyAirDash = true;
}
else
{
_animator.SetTrigger("GroundDash");
}
_isDash = true;
}
效果如下(請(qǐng)自行腦補(bǔ)xiu~xiu~xiu~的聲音):

5.角色射擊
射擊狀態(tài)是條件限制最多的狀態(tài),需要考慮的情況較多。首先,原地站立時(shí)按下上鍵會(huì)向上射擊。行走時(shí)按下射擊鍵會(huì)切換到行走射擊動(dòng)畫(huà),這里需要運(yùn)用到blend tree,如果僅按下射擊鍵則將Thresh值設(shè)置到向正前方射擊的區(qū)域;如果還按下了上鍵,需要將Thresh值設(shè)置到斜上方射擊的區(qū)域;如果放開(kāi)射擊鍵,還需要將Thresh值設(shè)置到初始未射擊的區(qū)域。如果處于跳躍狀態(tài),僅可向正前方和下方發(fā)射子彈。好吧,說(shuō)的我自己都暈了,但這里一定要注意這些細(xì)節(jié)的處理。
private void _Shoot()
{
_shootDic = new Vector2(_faceDic, 0); //射擊方向,為后面的子彈運(yùn)動(dòng)做準(zhǔn)備
_bulletBornPos = _standBulletPos; //子彈產(chǎn)生點(diǎn)
if (_isWalking && !_isJump) //在地面行走時(shí)射擊
{
if(Input.GetKeyDown(KeyCode.W) || Input.GetKey(KeyCode.W)) //若按下向上按鈕,動(dòng)畫(huà)變?yōu)橄蛐鄙戏缴鋼舻耐瑫r(shí)步行
{
_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) //設(shè)定行走射擊的時(shí)間間隔
{
_timeCount = 0f;
WalkShoot();
}
}
else if(!_isWalking && !_isJump) //原地站立時(shí)射擊
{
_animator.SetBool("IsShoot", true);
if (Input.GetKeyDown(KeyCode.W) || Input.GetKey(KeyCode.W)) //若按下向上按鈕則播放向上射擊動(dòng)畫(huà)
{
_animator.SetFloat("IdleState", 1f);
_shootDic = new Vector2(0, 1).normalized;
_bulletBornPos = _standUpBulletPos;
}
else
{
_animator.SetFloat("IdleState", 0f);
_bulletBornPos = _standBulletPos;
}
}
//蹲下及跳躍時(shí)射擊請(qǐng)參考源代碼
_isShoot = true;
}
運(yùn)行效果如下(吃我一發(fā)空氣彈?。?/p>
讓子彈飛
角色的基本操作暫時(shí)先介紹這么多,接下來(lái)要介紹子彈運(yùn)動(dòng)及動(dòng)畫(huà)效果該如何實(shí)現(xiàn)。
首先要做的,是要確定子彈的產(chǎn)生位置,為此,需要在角色身上按照不同的射擊動(dòng)畫(huà)時(shí)手指的位置設(shè)置好子彈誕生點(diǎn):

接下來(lái)就是該在何時(shí)產(chǎn)生子彈,如果是站立或下蹲時(shí)射擊,則在射擊動(dòng)畫(huà)的第一幀添加發(fā)射子彈的幀事件;若是在行走或跳躍時(shí)射擊,則按照一定的時(shí)間間隔產(chǎn)生子彈。同時(shí)在玩家的相關(guān)代碼中要將射擊子彈的消息傳遞給子彈管理器,這里我采用的是事件傳遞的方式:
public event Action<Vector2, Vector2> OnShoot; //站立射擊實(shí)踐
public event Action<Vector2, Vector2, Transform> OnWalkShoot; //行走射擊事件
public void ShootBullet() //站立射擊的幀事件
{
Vector2 bornPos = _bulletBornPos.position;
if (OnShoot != null)
{
OnShoot(bornPos, _shootDic);
}
}
//行走射擊時(shí)需要再傳一個(gè)子彈產(chǎn)生點(diǎn)的transform參數(shù),其余和站立射擊相似
我們還需要一個(gè)子彈管理器來(lái)生成子彈,將玩家的射擊事件傳入并在特定位置創(chuàng)建子彈,同時(shí),我們也可以在這個(gè)腳本中管理子彈生成時(shí)的特效。這里需要注意的是在行走時(shí)射擊的話,需要將子彈生成特效的父節(jié)點(diǎn)設(shè)置為子彈生成點(diǎn),這樣的話,biubiubiu特效就能跟著手指走啦;還需要特別指出一點(diǎn),向正前方射擊時(shí)每一顆子彈并不是在同一水平面上的,也就是說(shuō),子彈要有一定的垂直偏移,這樣才有一種子彈忽上忽下的視覺(jué)效果,在代碼中就需要一個(gè)列表來(lái)存儲(chǔ)子彈的垂直偏移量,通過(guò)循環(huán)獲取列表的方式改變子彈位置的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);
}
//行走射擊時(shí)需要設(shè)置子彈生成特效的父節(jié)點(diǎn),其余和站立射擊相似
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);
}
子彈的運(yùn)動(dòng)直接通過(guò)改變transform.position實(shí)現(xiàn),這里的重點(diǎn)之一是要將子彈旋轉(zhuǎn)至前進(jìn)方向,所以在開(kāi)始運(yùn)動(dòng)前要先運(yùn)用四元旋轉(zhuǎn)改變子彈的rotation值;第二點(diǎn)要注意的是,如果子彈接觸到地面或者敵人要有對(duì)應(yīng)的反饋(也就是擊中時(shí)的幀動(dòng)畫(huà)),這里我直接在子彈的預(yù)制體上加了trigger,運(yùn)用trigger enter來(lái)判斷是否要觸發(fā)相應(yīng)動(dòng)畫(huà):
public void StartMove(Vector2 shootDir) //開(kāi)始運(yùn)動(dòng)
{
_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); //根據(jù)子彈前進(jìn)方向?qū)ζ湫D(zhuǎn)
this.transform.rotation = rotate;
}
private void _Destroy() //子彈的銷(xiāo)毀
{
Destroy(this.gameObject);
_isMoving = false;
}
private void OnTriggerEnter2D(Collider2D collision)
{
if (collision.gameObject.layer == _groundLayer.value) //子彈觸碰到地面時(shí)觸發(fā)子彈擊中動(dòng)畫(huà)
{
_isMoving = false;
_animator.SetTrigger("Hit");
}
}
其實(shí),這種直接銷(xiāo)毀子彈的方法消耗很大,若想獲得更好的運(yùn)行效率,需要運(yùn)用到對(duì)象池,關(guān)于對(duì)象池,大家可以參考這篇文章【Unity】工具類(lèi)系列教程——對(duì)象池!
一切準(zhǔn)備就緒,那么就讓子彈飛起來(lái)吧:

And More
在茶杯頭中,角色行走時(shí),沖刺時(shí),落地時(shí)均會(huì)產(chǎn)生灰塵濺散的效果,滿滿的都是細(xì)節(jié)。那么為了表現(xiàn)這些效果,我們同樣要先確定好灰塵生成的位置并在玩家腳本中運(yùn)用事件觸發(fā)。接著和子彈一樣再創(chuàng)建一個(gè)灰塵管理器,這里也有個(gè)細(xì)節(jié)需要注意,游戲中行走時(shí)的灰塵是有多個(gè)種類(lèi)的(只能佩服制作組的良心),這里我只準(zhǔn)備了三個(gè)預(yù)制體,在每次生成行走灰塵時(shí)要隨機(jī)選取其中的一個(gè):
private GameObject _GetDustInstance(DustType rType) //根據(jù)灰塵的種類(lèi)獲取對(duì)應(yīng)的灰塵預(yù)制體
{
switch(rType)
{
case DustType.WalkDust:
int index = Random.Range(0, 3); //獲取隨機(jī)數(shù)以便隨機(jī)獲取行走灰塵
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);
}
最終的效果就像下面這樣啦(突然就酷炫了起來(lái)):

那么本期就介紹到這里了,完整代碼請(qǐng)移步:https://github.com/Yukimine33/CupheadCodeByMyself
其實(shí)角色邏輯并不難,但要有足夠的耐心去調(diào)整細(xì)節(jié),還要把大量的精力放在裁剪圖片及制作幀動(dòng)畫(huà)上,可想而知制作組花費(fèi)了多少心血才能給玩家?guī)?lái)這樣一部作品,也希望大家去多多支持這樣的良心作品。下一期會(huì)補(bǔ)全角色的基本操作并開(kāi)始Boss的制作。
想系統(tǒng)學(xué)習(xí)游戲開(kāi)發(fā)的童鞋,歡迎訪問(wèn) http://levelpp.com/
游戲開(kāi)發(fā)攪基QQ群:869551769
微信公眾號(hào):皮皮關(guān)