遊戲中的角色運動問題

狐王驾虎發表於2024-05-24

遊戲中的角色運動問題

大部分型別的遊戲中玩家都需要扮演一名主角,透過操縱主角來體驗遊戲,這就涉及到運動的問題。相信有不少Unity開發者都是從製作2D平臺跳躍遊戲入門的,從那時起到現在,也許有些關於運動的問題仍值得我們去思考,本文總結了一些個人學習時遇到的關於角色運動的問題及其學到的解決方法。

注意:這次的程式碼不太規範,只在一個指令碼上實現所有內容,輸入啥的也都混在一塊 (就圖一方便,因此僅供參考。

一、基礎問題

1. 平面移動

角色最基礎的運動方式,通常為了使玩家更好的與遊戲環境進行物理互動,我們會用剛體控制玩家的運動。(當然,如果你的遊戲想要獨特的運動體驗,那也可以透過Transform來運動,但這樣就需要你處理好物理碰撞問題)如何控制剛體運動比較合適呢?

  1. 透過AddForce等剛體 自帶的API

    image

    用這種方法的好處是 真實,這很符合現實的物理現象,我們透過力改變一個物體的加速度,再由加速度改變物體的速度,再在這種速度的作用下改變位移。但這種方式不直觀,你很難直接知道在這個力的作用下,角色的具體速度能否符合預期,由或者在某個彈跳力的作用下,角色能否跳到預期的高度。

  2. 直接修改velocity的數值
    剛體最終的運動狀態是由velocity屬性決定的,那直接修改velocity的值豈不更方便?的確,如果遊戲所需的運動並不複雜,那麼這種方式是可取的;但這樣做的結果就是不真實,例如,角色只會做勻速運動,缺失起步時加速和停止時減速的效果。除非……

  3. 除非你能保證 對velocity的修改符合物理規律(推薦)
    用方法1控制物體的運動過於複雜(要額外考慮物體質量啥的),而方法2又過於簡單,那麼我們可以綜合一下:用比控制力更簡單點的、但又是貼近真實的控制方法—— 控制加速度

    我們可以一起來試試,先建立一個名為TestMove的指令碼:

    using System.Collections;
    using System.Collections.Generic;
    using UnityEngine;
    
    [RequireComponent(typeof(Rigidbody))]
    public class TestMove : MonoBehaviour
    {
        [SerializeField] 
        private float maxSpeed = 10f; //最大速度
        [SerializeField] 
        private float maxAcceleration = 10f; //最大加速度
        
        private Rigidbody body; //物體掛載的剛體
        private Vector2 playerInput; //記錄玩家的輸入(前後左右)
        private Vector3 velocity; //臨時velocity,修改後會替換給剛體的velocity
        
        private void Awake()
        {
            body = GetComponent<Rigidbody>();
        }
        private void Update()
        {
            playerInput.x = Input.GetAxis("Horizontal");
            playerInput.y = Input.GetAxis("Vertical");
            playerInput.Normalize(); // 將輸入向量歸一化,避免輸入向量相加大於1
        }
        private void FixedUpdate()
        {
            velocity = body.velocity;
    
            //待處理的移動邏輯
            
            body.velocity = velocity;
        }
    }
    

    我們會依據設定的「maxAcceleration(最大加速度)」來調整速度的變化快慢,但要注意,這個加速度預期是以每秒為單位的,所以要乘上Time.fixedUpdateTime。
    接著,根據輸入訊號確定預期達到的速度;再借助 Mathf.MoveTowards 讓當前速度按照加速度大小向預期速度調整(當前速度 > 預期速度時,會以maxAcceleration減小;反之,會以maxAccelerate增大)。

    private void FixedUpdate()
    {
        velocity = body.velocity;
    
        var acceleration = maxAcceleration * Time.fixedDeltaTime;
        targetSpeed = new Vector3(playerInput.x, 0f, playerInput.y) * maxSpeed;
        velocity.x = Mathf.MoveTowards(velocity.x, targetSpeed.x, acceleration);
        velocity.z = Mathf.MoveTowards(velocity.z, targetSpeed.z, acceleration);
    
        body.velocity = velocity;
    }
    
    image

    透過第3種方式控制,我們可以得到想要的、自然的移動效果,而且程式碼效果可控、不復雜。

2. 地面檢測

是否“腳踏實地”,可是個重要問題。相當多的動作都需要玩家站在地面上才能完成,比如行走、跳躍。玩家現在站在地面上嗎?還是隻是靠在牆上呢?我所嘗試過的方案如下:

  1. 足部檢測線/盒
    在我入門做2D平臺跳躍遊戲時,很多案例都是採用這種方式:在角色底部放出一條(或者多條)射線,用這段射線檢測是否接觸地面(或者更進一步,用檢測到的物體的layer)。
    在3D遊戲中,可以將其替換為扁長方體放在角色底部來檢測 就跟穿鞋一樣

    image

    這種方式簡單,但卻有不少小紕漏:

    1. 誤判時機。當跳躍時,可能已經跳起一小段距離了,但射線仍可以接觸到地面,從而誤以為玩家仍在地面;落地時同理,可能會提早認為玩家落地了。

    2. 特殊情況。也許上面這種情況,你認為只要讓檢測的射線更短些就可以解決。那麼難免會出現以下幾種情況:檢測範圍太小,導致卡在凹陷中失去檢測,從而誤以為在空中;檢測高度太小,而檢測不到斜坡;檢測範圍太大,導致將接觸的側面牆壁也當作地面。

      image
    3. 牆地分離。在2情況中的前兩種都可以透過增加檢測線(盒)大小來解決,你或許會想:只要將牆和地面的Layer區分開來不就可以了?這在某些遊戲中確實可行,但很多關卡設計是允許玩家站在牆壁上方的。這樣的考量合理性不足。

  2. 接觸點的法線(推薦)
    這種方法運用了一點數學知識,請允許我簡單的介紹一番:

    1. 首先,你要知道 「法線」,也就垂直於某個平面的線,我們就稱這條線為那個平面的法線。無論一個平面如何扭曲,我們總能找到“立足”於那個平面某點上的法線。

      image
    2. 其次,你要知道三角函式中的cos,我們假設法線的長度都為1,可以發現當地面越來越陡峭時,法線在豎直方向上的投影,也就是它的cos值會越來越小,直到地面完全垂直(變成牆壁)時,這個值會變成0。

      image
    3. 在Unity中我們可以透過OnCollisionStay(OnCollisionEnter等也可以)函式,獲取碰撞到的物體,並進一步獲取碰撞點資訊,其中就包括了法線。

      image
    4. 如果只是地面檢測,這就已經足夠了,我們只需要在碰撞時,記錄下是否有遇到碰撞面的法線大於0的情況就可以了,只要這個法線大於0,就意味著這個法線的平面一定不是牆。
      當然,如果你覺得太陡的坡角色也不允許走的話,可以把0換成別的數字,比如0.4,這樣的話,比cos值為0.4的角度(也就是2中第三個坡的情況)還大的坡,玩家就走不了。
      我們在之前那個指令碼的基礎上,新增以下內容即可完成地面檢測:

      [SerializeField] private bool IsOnGround;
      
      void OnCollisionStay2D(Collision2D collision)
      {
          CheckCollision(collision);
      }
      void OnCollisionEnter2D(Collision2D collision)
      {
          CheckCollision(collision);
      }
      void CheckCollision(Collision2D collision)
      {
          for(int i = 0; i < collision.contactCount; ++i)
          {
              var normal = collision.GetContact(i).normal;
              if(normal.y > 0)
              {
                  IsOnGround = true;
                  groundNormal += normal;
              }
          }        
      }
      
      void FixedUpdate () 
      {
          velocity = body.velocity;
      
          var acceleration = maxAcceleration * Time.fixedDeltaTime;
          targetSpeed = new Vector3(playerInput.x, 0f, playerInput.y) * maxSpeed;
          velocity.x = Mathf.MoveTowards(velocity.x, targetSpeed.x, acceleration);
          velocity.z = Mathf.MoveTowards(velocity.z, targetSpeed.z, acceleration);
          
          body.velocity = velocity;
          IsOnGround = false; //新增的內容:物理運動結束就置為false
      }
      

3. 斜坡運動

我們預設移動的方向是水平的,但在實際情況中,可能會有斜坡出現。雖然我們的角色也能在斜坡上行走,但若不加以調整,那在斜坡上行走的速度就會 與預期有所偏差
可以看看如下對比(用的是正交相機觀察),這裡我設定了兩個最大速度為30、加速度為5的小球,下方的那個有進行調整,而上方的沒進行調整,可以發現上方的速度稍慢與預期(有些情況下也會稍快,總之就是不符合預期):

image image

造成這種現象的原因很明顯,那就是移動的方向與地面不水平,導致了其分解。所以解決的辦法便是:將移動的方向調整成與當前地面水平,就像下面這樣:

image

在發生碰撞時先記錄下能被地面的法線;

private Vector3 groundNormal;

void OnCollisionStay(Collision collision)
{
    for(int i = 0; i < collision.contactCount; ++i)
    {
        var normal = collision.GetContact(i).normal;
        if(normal.y > 0)
        {
            IsOnGround = true;
            //因為接觸點可能不止一個,要先累加所有的法線向量,使用時會歸一化
            groundNormal += normal;
        }
    }
}

再做一個能讓向量投影在當前觸碰地面的函式,得到與當前地面平行的向量(其實就是投影):

private Vector3 GetProjectOnPlane(Vector3 curVector)
{
    //當前向量 - 法線向量 * (當前向量投影在法線的長度)
    //就能得到當前向量投影在 產生法線的面(也就是地面)的向量
    //注意:長度沒進行處理,後續使用時根據需要進行歸一化
    return curVector - groundNormal * Vector3.Dot(curVector, groundNormal);
}

有了這個函式我們可以改改之前的行走邏輯,並把它寫在Move函式中:


void FixedUpdate () 
{
    velocity = body.velocity;
    groundNormal.Normalize();//進行運動處理前,記得歸一化當前地面的法線

    Move();
    
    groundNormal = Vector3.up;//預設地面法線是豎直向上(世界座標y軸)的
    body.velocity = velocity;
    IsOnGround = false;
}
void Move()
{
    var acceleration = maxAcceleration * Time.fixedDeltaTime;
    targetSpeed = new Vector3(playerInput.x, 0f, playerInput.y) * maxSpeed;

    //以下為進一步修改的部分
    var xAixs = GetProjectOnPlane(Vector3.right).normalized;//獲取平行於地面運動的x軸
    var zAixs = GetProjectOnPlane(Vector3.forward).normalized;//同理獲得z軸

    var curVelocityX = Vector3.Dot(velocity, xAixs);//透過投影,獲取當前x軸上的運動速度
    var curVelocityZ = Vector3.Dot(velocity, zAixs);//同理,獲取當前z軸上的運動速度

    // velocity.x = Mathf.MoveTowards(velocity.x, targetSpeed.x, acceleration);
    // velocity.z = Mathf.MoveTowards(velocity.z, targetSpeed.z, acceleration);
    // body.velocity = velocity;

    //計算這幀加速後的新x軸、z軸(左右、前後)的速度
    var newVelocityX = Mathf.MoveTowards(curVelocityX, targetSpeed.x, acceleration);
    var newVelovityZ = Mathf.MoveTowards(curVelocityZ, targetSpeed.z, acceleration);
    
    //透過累加的方式,計算最終剛體的速度
    velocity += xAixs * (newVelocityX - curVelocityX) + zAixs * (newVelovityZ - curVelocityZ);
}

這樣就可以保證在斜坡上的運動時的速度了。

二、2D遊戲的運動(平臺跳躍類)

在2D遊戲中,值得探討的運動問題主要是關於橫向平臺類的,一般俯視角的2D遊戲不會有太複雜的移動方式(也或許只是我沒遇到),故不會討論它。

image

注意:在「基礎問題」部分的展示程式碼都是基於3D的,但稍加修改也可以用於2D(新增了簡單的跳躍功能):


using System.Collections;
using System.Collections.Generic;
using Unity.VisualScripting;
using UnityEngine;

[RequireComponent(typeof(Rigidbody2D))]
public class TestMove2D : MonoBehaviour
{
    [SerializeField] 
    private float maxSpeed = 10f; //最大速度
    [SerializeField] 
    private float maxAcceleration = 10f; //最大加速度
    [SerializeField]
    private float jumpHeight = 2f;//跳躍能到達的預期高度
    private Vector2 jumpDirection;//跳躍的方向

    private Rigidbody2D body;
    private float playerInput;
    private Vector2 targetSpeed;
    private Vector2 velocity;
    
    private Vector2 groundNormal;
    private bool IsOnGround;
    private void Awake()
    {
        body = GetComponent<Rigidbody2D>();
    }

    private bool isTryJump;
    void Update () 
    {
		playerInput = Input.GetAxis("Horizontal");
        playerInput = Mathf.Min(playerInput, 1);
        //記錄跳躍按鍵,這裡不能直接用賦值,因為Update執行間隔和FixedUpdate不同
        //可能當前按下了跳躍,但FixedUpdate卻還沒來得及跳起來,卻又執行了一次Update
        //如果直接用等號賦值,此時就會被視作沒有按下跳躍鍵
        //所以,應當嚴格保證只有在FixedUpdate起跳後,isTryJump才為false
        isTryJump |= Input.GetButtonDown("Jump");
	}

    void OnCollisionStay2D(Collision2D collision)
    {
        for(int i = 0; i < collision.contactCount; ++i)
        {
            var normal = collision.GetContact(i).normal;
            if(normal.y > 0)
            {
                IsOnGround = true;
                groundNormal += normal;
            }
        }
    }

	void FixedUpdate () 
    {
		velocity = body.velocity;

        groundNormal.Normalize();
        Move();
        Jump();
        groundNormal = Vector2.up;

        body.velocity = velocity;
        IsOnGround = false;
	}

    void Jump()
    {
        if(isTryJump)
        {
            isTryJump = false;
            if (IsOnGround)
            {
                jumpDirection = groundNormal;
            }
            else
            {
                return;
            }
            // vt = 根號(2gh),Unity的y方向重力是-9.81,所以計算時g取個負號,使其成正數
            var jumpSpeed = Mathf.Sqrt(2 * -Physics.gravity.y * jumpHeight);
            //透過投影獲取當前跳躍方向的速度
            var curSpeed = Vector2.Dot(velocity, jumpDirection);
            if(curSpeed > 0)
            {
                jumpSpeed = Mathf.Max(jumpSpeed - curSpeed, 0);
            }
            velocity += jumpSpeed * jumpDirection;
        }
    }

    void Move()
    {
        var acceleration = maxAcceleration * Time.fixedDeltaTime;
        targetSpeed = new Vector2(playerInput, 0f) * maxSpeed;
        var xAixs = GetProjectOnPlane(Vector2.right).normalized;
        var curVelocityX = Vector2.Dot(velocity, xAixs);
		var newVelocityX = Mathf.MoveTowards(curVelocityX, targetSpeed.x, acceleration);
        velocity += xAixs * (newVelocityX - curVelocityX);
    }

    Vector2 GetProjectOnPlane(Vector2 curVector)
    {
        return curVector - groundNormal * Vector2.Dot(curVector, groundNormal);
    }
}

1. 土狼時間

土狼時間允許玩家在 離開平臺的短時間內仍可以進行跳躍,它的本質是對玩家操作的「寬容」,很多時候玩家為了跳出更遠的距離,會希望在平臺邊緣極限位置開始跳躍。但這個時機不好把控,一是因為遊戲引擎本身物理更新存在間隔,二是人的反應延遲,因此這種「寬容」處理是能最佳化玩家體驗的。

image

PS:“土狼時間”的名字由來:

image

土狼時間的實現並不難,關鍵是要處理好一個細節,即「離開平臺」應當是指走出平臺,要與因跳躍等其它原因離開平臺的情況區分。否則容易出現“二段跳”等問題。

有一種可取的思路是:透過記錄離開地面後,FixedUpdate函式執行的次數來作為土狼時間的長度,並也作為跳躍的條件判斷依據之一,且在執行一次跳躍後立即置否,以避免“二段跳”(這裡順帶實現了跳躍,或許看程式碼會清楚些):

private int stepsLastGround; //離開地面的時間(以FixedUpdate執行次數來算)

void FixedUpdate () 
{
    velocity = body.velocity;
    //新增內容,每次進行運動計算前,維護好stepsLastGround的值
    stepsLastGround = IsOnGround ? 0 : stepsLastGround + 1;

    groundNormal.Normalize();
    //其它不變
}

void Jump()
{
    if(isTryJump)
    {
        isTryJump = false;
        //可以認為預設情況下一次FixedUpdate用時0.02s
        //這裡stepsLastGround < 20,則意味著走出平臺的時間 < 0.2s (即20 * 0.02s)
        if (IsOnGround || stepsLastGround < 10)
        {    
            jumpDirection = groundNormal;
            stepsLastGround += 10; //在土狼時間內跳過後,就不能再跳了
        }
        //其餘不變
    }
}

比較誇張的實現效果(通常土狼時間不用那麼長):

image

2. 邊緣檢測

邊緣檢測同樣是對玩家操作的一種寬容手段,它針對的是細微的邊緣碰撞帶來的問題,例如,玩家想從平臺邊緣處下方跳躍時,可能會因為細微的碰撞而導致失敗:

image

當然,換個 膠囊體或球體碰撞器 或許也能解決這個問題,只不過會出現 邊緣位置站不穩,而且來看看其它解決方法也不虧,沒準以後就遇到了不得不使用矩形碰撞器的情況呢。

image

這個方法很簡單:在即將撞到平臺時,透過修改位置(而非運動方向)的方式將角色往遠離碰撞的位置移一點點,這個過程中玩家的運動是保持著的。因此,如果偏移後的位置合適,玩家看起來就會像被誰助力了一把。

但往那一推是否有效我們得先進行判斷:

  1. 先將物體往遠離障礙的地方移動一步;
  2. 再模擬物體以原本的速度運動一步;
  3. 如果在執行2後的位置沒有碰到障礙,證明可以推,反之不行。
image

程式碼實現如下:

[SerializeField]
private LayerMask groundLayer; //設定地面層,用於碰撞檢測

void FixedUpdate () 
{
    velocity = body.velocity;
    stepsLastGround = IsOnGround ? 0 : stepsLastGround + 1;

    groundNormal.Normalize();
    Move();
    Jump();
    EdgeDetection();//新內容:邊緣檢測
    groundNormal = Vector2.up;

    body.velocity = velocity;
    IsOnGround = false;
}

void EdgeDetection()
{
    //邁出一步的距離
    var move = (Vector3)velocity * Time.fixedDeltaTime;
    //繼續前進後的位置
    var furthestPoint = transform.position + move;
    //如果前進後的位置有檢測到指定層,說明即將發生碰撞
    var hit = Physics2D.OverlapBox(furthestPoint, myCollider.bounds.size, 0, groundLayer);

    if (hit)
    {
        //遠離障礙的方向
        var dir = (transform.position - hit.transform.position).normalized;
        //移動1、2步驟後的位置
        var tryPos = furthestPoint + dir * move.magnitude + move;
        //如果新位置沒有碰撞,說明可以進行偏移
        //這裡要排除接觸地面的情況下,否則會誤認為一直有碰撞
        if (!IsOnGround && !Physics2D.OverlapBox(tryPos, myCollider.bounds.size, 0, groundLayer))
        {
            transform.position = transform.position + dir * move.magnitude;
        }
    }
}

最終效果如下:

image

順帶一提,這種處理方式也能解決玩家差一點就能跳上平臺的情況:

image

3. 額外重力

平臺跳躍類遊戲的「跳躍」是比較特殊的,通常我們需要做到以下兩點,才能保證有好的跳躍體驗而不會覺得很飄:

  1. 跳躍高度會隨按下跳躍鍵的時間而變化,做到短按跳得矮,久按跳得高(有種透過按鍵來施加跳躍力的感覺);
  2. 跳躍下落更乾脆,對於同等高度,通常下降的時間會比跳躍上升的時間更短。

透過新增額外的重力,我們可以一齊解決這兩個問題。思路是這樣的:

  1. 當玩家處於下落狀態時,我們就為它施加額外重力,使下落過程加快,這樣就能解決2問題。
  2. 當玩家在跳躍上升過程中,如果沒按住跳躍鍵,就進一步施加更大重力,這樣短按就跳得矮了,相對的長按就跳得高,解決1問題。
[SerializeField]
private float maxFallSpeed = 40; //最大下落速度
[SerializeField]
private float fallAcceleration = 110; //下落加速度
[SerializeField]
private float jumpEndFactor = 3; //跳躍過程中短按帶來的額外重力放大倍數
[SerializeField]
private float fallFactor = 0.3f;//下落時的額外重力放大倍數
void ExtraGravity()
{
    var jumpSpeed = Vector2.Dot(velocity, jumpDirection);
    if (!IsOnGround) //不在地面,即處於上升或下降狀態時
    {
        var inAirGravity = fallAcceleration;
        if(jumpSpeed > 0f) //如果上升
        {
            if (!Input.GetButton("Jump")) //上升過程中,沒按跳躍鍵時會受到額外重力
            {
                inAirGravity *= jumpEndFactor;
            }                
        }
        else
        {
            inAirGravity *= fallFactor;
        }
        jumpSpeed = Mathf.MoveTowards(jumpSpeed, -maxFallSpeed, inAirGravity * Time.fixedDeltaTime) - jumpSpeed;
        velocity += jumpSpeed * jumpDirection;
    }
}

讓我們看看最後效果如何(可以自行修改相關引數來調整跳躍效果):

image image

但注意,經過這樣的修改後,原本用於設定跳躍高度的jumpHeight就不準確了,jumpHeight此時只能當跳躍力來看待。

相關文章