在Unity實現角色攀爬
前言
開放世界型別的遊戲近年也熱門起來了,自由攀爬也成了這一類遊戲的一大特色。攀爬給了玩家更多探索路徑的選擇,也讓地圖設計有了更多思路。這次,我們就來嘗試在Unity中製作一個人形角色的攀爬。
注:攀爬是一個角色完整動作系統的一部分,本文暫且拋開其它動作,也不涉及動畫,僅針對攀爬邏輯的實現這一點。
主要實現
首先,我們要意識到,遊戲中的攀爬行為已經與物理系統沒有太大關係了。在攀爬時,角色實際上進入了一種“懸浮”狀態,然後貼著牆面運動。攀爬系統要做好,就在於如何能讓角色貼著牆面運動。
或許說到這,你腦海裡已經想到了很多千奇百怪的攀爬面,但其實,任何攀爬面只要能抓住其法線,一切都好解決很多了:
-
先寫一個輔助函式,將向量投影在某一法線所屬平面(得到的就是那條深藍色向量):
/// <summary> /// 獲取向量在某一平面的投影 /// </summary> /// <param name="vector">要投影的向量</param> /// <param name="planeNormal">平面的法線(需歸一化)</param> /// <returns>在平面的歸一化投影向量</returns> public static Vector3 GetProjectOnPlane(Vector3 vector, Vector3 planeNormal) { return (vector - planeNormal * Vector3.Dot(vector, planeNormal)).normalized; }
-
如果能獲取攀爬面的法線,我們就可以將角色的運動方向轉化為在攀爬面上的運動方向:
var newXAxis = GetProjectOnPlane(xAxis, contactNormal); var newZAxis = GetProjectOnPlane(zAxis, contactNormal);
那現在的問題就在於如何準確獲取攀爬面的法線?或許你會想到用射線檢測,但遇到內角與外角的情況又該如何檢測呢:
難不成要換成其它形狀的碰撞檢測?其實沒必要這麼麻煩,我們完全可以利用角色自身的碰撞體接觸來判斷。大多數情況下,人形角色都是使用Capsule Collider(膠囊體碰撞盒)
,能較“均勻”地觸碰接觸面,比如在接觸到內直角的情況下,將所有接觸點的法線累加再求平均,得到的平均法線向量是接近45度,這是膠囊體曲面性質導致的。
private void OnCollisionEnter (Collision collision)
{
EvaluateCollision(collision);
}
private void OnCollisionStay(Collision collision)
{
EvaluateCollision(collision);
}
/// <summary>
/// 在OnCollisionEnter和OnCollisionStay中呼叫,用於獲取接觸到的碰撞有效資訊
/// </summary>
/// <param name="other">接觸的碰撞體</param>
public void EvaluateCollision(Collision collision)
{
int layer = collision.gameObject.layer;
for(int i = 0; i < collision.contactCount; ++i) //檢查接觸點型別並記錄對應型別的法線
{
Vector3 normal = collision.GetContact(i).normal;
float upDot = Vector3.Dot(upAxis, normal);
//如果當前可以攀爬、攀爬面層級為可攀爬層級、攀爬面的傾斜角度未超過最大攀爬角度
if(isAllowedClimb && ((1<<layer) & climbMask) != 0 && upDot >= minClimbDot)
{
++climbContactCnt; //統計接觸點數量,便於後續求平均
climbNormal += normal; //累加攀爬法線,便於後續求平均
lastClimbNormal = normal;
}
}
}
/// <summary>
/// 檢測攀爬並更新、歸一化攀爬法線,攀爬時呼叫
/// </summary>
/// <returns>true為可攀爬,false為不可攀爬</returns>
public bool CheckClimb()
{
if(IsClimbing)
{
if(climbContactCnt > 1)
{
climbNormal.Normalize();
//如果處於裂縫中(四周攀爬面法線和為地面),就取檢測到的最後一個面為攀爬面
var upDot = Vector3.Dot(upAxis, climbNormal);
if(upDot >= minGroundDot)
{
climbNormal = lastClimbNormal;
}
}
contactNormal = climbNormal;
return true;
}
return false;
}
這裡提兩點:
-
為什麼
upDot >= minClimbDot
可以用來判斷是否更傾斜?
在往期文章中,我有提到過:假設法線的長度都為1,可以發現當地面越來越陡峭時,法線在豎直方向上的投影,也就是它的cos值會越來越小,直到地面完全垂直(變成牆壁)時,這個值會變成0。所以,只要事先將「可攀爬的最大角度」的cos值算出,我們就能將角度的比較轉為數值的比較。[SerializeField, Range(90, 180), Tooltip("最大攀爬角度")] private float maxClimbAngle = 140f; minClimbDot = Mathf.Cos(maxClimbAngle * Mathf.Deg2Rad);
-
為什麼要記錄
lastClimbNormal
?
這是為了防止類似下圖這種情況,角色接觸面法線的平均為Vector3.zero
,此時角色將爬不動,故將「最後接觸到的那條法線」作為攀爬面法線。
上述這些就已經能接近內角的問題了,但外角還需要一點額外處理——擠壓,攀爬時向角色持續施加一個沿著法線向牆面的力:
float maxClimbAcceleration = 40f;//攀爬時的加速度
//用於攀爬外牆角時貼緊牆面
Velocity -= contactNormal * (0.9f * maxClimbAcceleration * Time.fixedDeltaTime);
取90%的攀爬運動的加速度作為這個力的大小,可以保證擠壓力不讓角色動彈不得。現在,內外角的攀爬就沒有太大問題了(紅色的是接觸面法線):
額外調整
然而,事情並沒有結束。攀爬時我們通常還會讓角色始終面向攀爬面,這就需要我們在攀爬時適時旋轉角色,這也不困難:
public void ClimbForward() //旋轉以面向攀爬牆面
{
if(sensor.climbNormal != Vector3.zero)
{
var forwardQ = Quaternion.LookRotation(-climbNormal, upAxis);
transform.rotation = forwardQ;
}
}
而一旦這麼做了,那麼當你嘗試攀爬以下形狀的面,會發現角色頻頻抽搐、退出攀爬狀態:
為什麼會這樣?我們來分析下這個過程:
- 角色沿著牆面向上爬,一切正常
- 當角色頂部接觸到上斜面時,計算出的法線發生了改變,角色也要進行旋轉以面向新法線
- 問題就發生在這裡,角色是膠囊體,在旋轉後可能就與牆面衝突了。而且旋轉後接觸牆面的區域也變了,法線又發生了變化,而法線一旦變化,角色又得旋轉,而一旦旋轉後……
不難看出,罪魁禍首其實是膠囊體(攀爬的實現邏輯不改變的話(。・ω・。))!膠囊體橫向旋轉時勢必會影響接觸區域,導致計算出的法線變化,從而帶來一系列問題。除非你的角色是個球形的,這樣,隨便旋轉都不會影響接觸區域了……
是啊!不妨僅在攀爬時將角色的碰撞體進行「變形」,從膠囊體變為球體:
private void SetClimbCollider(bool isClimbing)
{
if(isClimbing)//正在攀爬時,將膠囊體的高度設為0,就變球體了
{
playerCollider.height = 0;
playerCollider.radius = climbColliderRadius;
playerCollider.center = climbColliderCenter;
}
else //退出攀爬時,將引數還原以變回原本的膠囊體
{
playerCollider.height = colliderHeight;
playerCollider.radius = colliderRadius;
playerCollider.center = colliderCenter;
}
}
除了將膠囊體高度設為0,我們還適度增加了膠囊體半徑,以及將中心偏移,通常是改成能覆蓋角色上半身的情況 攀爬時,腿真的不重要了 :
萬事大吉了嗎?還差一步,仍是旋轉的問題。
一般的人形角色的根物體位置只是角色底部中心處,平時涉及的運動也是圍繞這個點進行的,但如今我們想要角色能繞調整後的球形碰撞體的球心旋轉,因為只是繞根位置旋轉,很可能讓角色失去碰撞接觸(示意圖中,將角色模型簡化成了棒棒糖形狀):
這就相當於將根物體位置改為球心了呀,這還有點麻煩,畢竟會干擾原本的運動邏輯。除非有什麼巧妙的旋轉策略,鄙人倒是有個想法,定然不是最好的,大家如果自己有思路,也可以跳過這段,程式碼如下:
//繞攀爬時的球心(攀爬時膠囊體會變成球體)旋轉,以面向攀爬牆面
public void ClimbForward()
{
/*總體思路: 先繞根位置旋轉以調整面朝的方向,但旋轉點並不是球心
只是這樣旋轉的話,必定會讓球心偏離原來位置,
所以要讓playerTransform補回那段距離,以讓球心回到旋轉前的位置
這樣就實現了:既朝向了攀爬法線,球心位置又不改變 = 繞球心旋轉*/
if(sensor.climbNormal != Vector3.zero)
{
var originCenter = playerTransform.position + playerTransform.up * playerCollider.center.y;
var forwardQ = Quaternion.LookRotation(-sensor.climbNormal, sensor.upAxis);
playerTransform.rotation = forwardQ;
var newCenter = playerTransform.position + playerTransform.up * playerCollider.center.y;
playerTransform.position += originCenter - newCenter;
}
}
尾聲
有關攀爬的核心實現大概就這些了,再次強調一遍,這是完整動作系統的一部分,我從自己實現的一個專案中剝離出來的,顯得不太完整(因為完整的會涉及很多為了配合其它動作而設的一些變數,有點喧賓奪主了),本身只是一個思路分享,大夥有更多想法也可以分享出來呀~當然,有不滿之處也可指出(。・・)ノ