遊戲開發日誌 #06


這次會淺談程序的簡化、系統的整合性,同時分享優化後的自訂物理的運作和程式碼。

如果還沒有看過上一篇的自訂物理的話,可以先去看看,當中說明了為什麼要使用自訂訂物理。

遊戲開發日誌 #05

程序的簡化

程序可以簡化到什麼程度?這就要看程序員對開發語言的了解程度了。

例如:
private int i=0;

private void Update() {
    i++;
    if (i > 3) {
        i=0;  
    } 
}
在這個 Update 循環中的 4 行程序,其實可以變成這樣:
private int i=0;

private void Update() {
    i = i > 3 ? 0 : i + 1;
}

這是最基本也是最低限度的程序簡化,也就是優化,否則就會出現 Code Smell 代碼異味,即是程序並沒有錯誤,但卻不好。

有很多情況會出現 Code Smell,例如不停出現重複的程序,這些程序應該抽起來做一個獨立的 Function 函數來運作;反過來無意義地建立 Function 也會有異味,Function 或 Variable 的名稱不當或不能代表其作用也是異味的一種。

程序的簡化還包括了一些自訂的 extension methods, custom class, struct 等等,都能有效地縮減編程的時間和簡化程序。
下面的自訂物理程序也必須配合本人的 extension methods 來使用,例如:
  • 想把 transform.position 的 y 改成零的話。。。
  • transform.position = new Vector3(position.x, 0, position.z);
  • 但使用本人的 extension methods 的話。。。
  • transform.position = transform.position.y(0);
  • 相反希望保留 y 的值,把 x 和 z 改成零的話。。。
  • transform.position = transform.position.y(0, 0);
這部份只能靠經驗來累積,所以只要一直要求自己學更多做更好,自然會做到。


系統的整合性


如果系統是由自己一手一腳搭建的話會很簡單,一切都是自己希望有的功能和作用,也能好好地配合自己的作法和習慣。

但對於新或小型的遊戲團隊,開發工程基本是使用現成的遊戲引擎,也就是在別人的系統建立劇本去運作,在這層面上要做到簡潔的首要條件就是了解其系統的運作了。

除了要了解系統的運作外,還要對 Design Pattern 設計模式有一定的認知,這樣在設計上才會有系統,能有效避免混亂和不必要的錯誤,也能讓 Debug 的工作變得簡單快捷。

在遊戲創作中,系統就是遊戲引擎,在本人的情況就是 Unity 3D 了。
在 Unity 中編寫劇本,就要好好配合 Unity 的運作,才能夠用最簡潔的程序做到最有效率的工作。
系統中首要了解的是 processing cycle 運作周期,在遊戲引擎中就是 Rendering Cycle 和 Physics Cycle。
Rendering Cycle 是畫面的更新周期,也就是 Frame 幀,在一幀開始到一幀結束中所運行的劇本變化,都會在 Frame end 後 Render 成一個畫面,然後更新到螢光幕上。
Physics Cycle 是物理的更新周期,一切和物理相關的東西都會在這個 cycle 完結後運算遊戲世界中所有 Rigidbody 的物理反應,包括碰撞、速度、重力反應等等。

只要能好好配合好系統的運作,就能夠有最佳的效果。
在這部份最多人做錯的,就是讓帶 Rigidbody 的物體在 Rendering Cycle 中調整,或者明明在 Physics Cycle 中,卻在調整 Transform,結果出現像 Drop Frame 掉幀的情況。這是因為兩個周期並不一樣,所以才會有問題。

在 Unity 中,最大的特性就是 Monobehaviour。
Monobehaviour 讓一個 class 可以成為 GameObject 的 Component,只要 GameObject 帶有這些 Class,就可以在系統的運作周期執行所需的劇本,達到想要的效果。
但我們不是隨便就去建立 Class 工作,也是要有系統的,簡單說就是同一類的事情不要分成幾個 class,也不要一個 class 去做不同類型的事情。

在接著分享的自訂物理,就是對攜帶自訂物理這個 class 的物件在 physics cycle 中應環境而調整自身重力,達到自然而沒有違和感的物理效果。
當中包括了移動、轉動、爬坡、跳躍、反彈、下墮,看似都是不同的東西,其實也只是物體本身的速度變化,如果真的分開不同的 class 處理,由於各自的情況都會讓別的情況終止,就必須要再做先後執行或不執行的處理,但因為是不同的 class,系統不會有固定的執行次序,如果沒有機制去控制次序,就會因為互相干擾而出現不規則的錯誤,即使能好好控制執行次序,如果要增減功能,或改變功能的運作,修訂的時間和麻煩都會大增,再產生錯誤的機會都會大增。

有一個很好的概念叫做 Model-view-control (MVC),如果在控制角色上出問題,這也是一門必須了解的課題,主要就是把系統架構分成三個部份,這個架構有效做到從控制角色到畫面顯示效果的配合。
本人在上周以這個概念,把 input manager 和 controller 重新架構完成,讓設定、執行、效果都大大提升。

這部份也只能靠經驗來累積,所以還是同一句,只要一直要求自己學更多做更好,自然會做到。


優化後的自訂物理

在很多文字後,來到了分享程序的部份了。

文字看多了,先來看看測試影片吧。


在影片中可以看到:
  • 角色在平地、上下坡的移動和跳躍
  • 角色在跳到 NPC 上會被彈開
  • 只改變角色的速度後,角色的跳躍也會加強
  • 跳躍時的移動速度會降低
  • 起跳到頂點的時間比頂點到著地的時間長
以上這些東西都不用很複雜的運算,或用上什麼很驚人的方程式,以下就去看看這是如何運作的吧。

在影片的後段,把角色的移動速度加大,但跳躍的參數不改變,就會發現角色在跳躍時的飄浮感很強,這就是上一篇所說的問題。
現在這個測試角色並不是高速角色,所以重力的調整並不用太強,現實重力的加速力是 9.81 m/s^2,現在這個角色的反計算重力是 12.5 m/s^2,跟 Mario 差不多,如果是高速角色,其重力便要加大更多才能夠消除飄浮感。
當然影片中的高速只是測試用,並不會用在遊戲中。

注1:在影片中是本人的遊戲實作,所以架構是不同的,下面的程序都把所需的東西放回這個 class 中。
注2:由於是本人的遊戲設定,如有不需要的地方,請自行修正或移除,我會盡力去說明如何修改或移除的。


<必要 Components>
using System.Collections.Generic;
using UnityEngine;

[RequireComponent(typeof(Rigidbody))]
[RequireComponent(typeof(Collider))]
public class CustomPhysics : MonoBehaviour
{
}
這個自訂物理只自訂角色的速度,一切的碰撞都交回 Physics Engine 去處理,所以 Rigidbody 是必要的。
Collider 是自動取得角色的半徑和最底部位置。
Class 名字預定是 CustomPhysics


<參數 Parameters>
    [Header("Settings")]
    public LayerMask groundLayer;
    public LayerMask reboundLayer;
    public List exclusedFromCollision;
    public float radius = 0f;
    public float colliderSkin = 0.05f;
    public float maxSlope = 40f;

    [Header("Components")]
    public Collider col
    public Rigidbody rb

    [Header("Jump Settings")]
    public float moveJumpRatio;
    public float maxJumpHeight;
    public float minJumpHeight;
    public float timeToJumpApex;
    public float jumpDecelerateRate;

    [Header("Rebound Setting")]
    public float reboundPower;

    [Header("public Parameters")]
    public bool isGrounded;
    public float inAirHeight;
    public bool isFalling;
    public bool isClimbing;
    public bool isJumping;
    public bool isRebounding;

    // Physic
    private float Gravity
    {
        get
        {
            return (2 * maxJumpHeight) / Mathf.Pow(timeToJumpApex, 2);
        }
    }
    private Vector3 velocity;
    private Vector3 RayPos
    {
        get { return rb.position.y(col.bounds.min.y); }
    }

    // Move
    private Vector3 moveVelocity;
    private float moveRatio;
    private Quaternion rotation;
    private float rotateSpeed;
    private float slopeAngle;
    private float platformAngle;
  
    // Jump
    private float jumpRatio;
    private float MaxJumpVelocity
    {
        get { return Gravity * timeToJumpApex * jumpRatio; }
    }
    private float MinJumpVelocity
    {
        get { return Mathf.Sqrt(2 * Gravity * minJumpHeight); }
    }

    // Status
    private float lastGravity ;
    private bool makeJump;
    private bool minJump;
    private bool makeRebound;
    private bool isMoving;
    public bool isStandingOnSomething;
    private Vector3 oldPosition;

比上一篇多了很多參數吧,只是要做的東西多了而已。

設定參數:
  • groundLayer:地面 Layer
  • reboundLayer:反彈 Layer,踏上去就會彈開
  • exclusedFromCollision:不偵測的 GameObject,例如自己
  • radius:半徑,設成 0 會自動取得 Collider 的半徑
  • colliderSkin:碰撞器皮膚厚度,預定的就可以
  • maxSlope:最大爬坡角度
  • col:角色的 Collider 碰撞器
  • rb:角色的 Rigidbody 剛體
  • moveJumpRatio:移動速度跳躍高度比率
  • maxJumpHeight:最大跳躍高度
  • minJumpHeight:最少跳躍高度
  • timeToJumpApex:起跳到頂點所需時間
  • jumpDecelerateRate:跳躍中移動減速比率
  • reboundPower:反彈力度
必要數值(不用 × Time.DeltaTime):
  • moveRatio:移動比率,0 - 1,角色的移動速度比率,必須自行匯入
  • moveVelocity:移動速度
  • rotation:轉動方向
  • rotation:轉動速度
自動運算的數值:
  • Gravity:利用最大跳躍高度和到達頂點所需時間來計算
  • RayPos:角色和底部位置
  • MaxJumpVelocity:最大跳躍力量
  • MinJumpVelocity:最小跳躍力量

<初始化 Initialization>
    private void Awake()
    {
        if (radius == 0)
            radius = col.bounds.extents.x;
        velocity = Vector3.zero;
    }
  • 如果 radius == 0,則會套上 col 的半徑
  • 把 velocity 設定 0,0,0


<外部呼叫 External Call>
    public void SetMoveVelocity(Vector3 vel, float mRatio)
    {
        moveVelocity = vel;
        moveRatio = mRatio;
    }

    public void SetRotation(Quaternion rot, float sp)
    {
        rotation = rot;
        rotateSpeed = sp;
    }

    public void StartJump()
    {
        if (isGrounded && !isJumping)
            makeJump = true;
    }

    public void MinJump()
    {
        if (isJumping && velocity.y > minJumpVelocity)
            minJump = true;
    }
跟上一篇最大的差別,就是把 move 和 rotation 都納入這裡處理,並加入了跳躍的控制。
請在自己的 controller 或 platformer 中傳到這裡。
以下相關的數值都不用剩以 Time.DeltaTime。
  • SetMoveVelocity(Vector3 vel, float mRatio)
    角色移動時呼叫。
    vel 是角色的速度。
    基本是向前的,transform.forward.normalized * moveSpeed 就可以,靜止的話也可以設定 Vector3.zero。
    mRatio 則是速度的比率,主要是以速度來調整跳躍力的參數。
    數值介乎 0-1 之間,是遊戲中最高速度的比率,例如:
    遊戲中最高速是 200,而這個角色的速度是100,那 mRatio 就是 0.5f。
    如果不用 mRatio 的話,請自行移除相關的程序。
  • SetRotation(Quaternion rot, float sp)
    角色轉向時呼叫。
    rot 是角色轉向的方向。
    sp 是角色轉動的速度。
  • StartJump()
    角色起跳時呼叫,只要呼叫一次就足夠。
    當角色是著地中和並不是跳躍中,(bool)makeJump = true,在重力修正時會進行起跳。
  • MinJump()
    放開跳躍按鈕時呼叫,控制角色不跳到最大跳躍高度。
    當角色正在跳躍中而調整的 velocity.y 大於最小跳躍力度時,(bool)minJump = true,在重力修正時會讓跳躍中的角色開始著地。


<每物理循環 Every Physics Cycle>
    private void FixedUpdate()
    {
        // Checking
        CheckGround();
        CheckPlatform();
        CheckSlope();

        // Gravity Adjustment
        MoveRotate(ref velocity);
        Rebound(ref velocity);
        Climbing(ref velocity);
        Jump(ref velocity);
        Falling(ref velocity);

        // Apply
        Rb.velocity = velocity;

        // Records
        lastGravity = Rb.velocity.y;
        oldPosition = Rb.position;
    }
每次物理循環執行的東西分了四項,檢查、重力調整、套用、記錄。
  • 檢查會進行腳底偵測以及上坡偵測。
  • 動力調整會進行角色的移動轉向、反彈、爬坡、跳躍以及下墮的重力調整,並記錄這一次物理循環中角色的速度在 (Vector3)velocity 這個變數中。
  • 套用會把記錄中的 (Vector3)velocity 套用到 Rigidbody.velocity 上。
  • 記錄會把現在的 Gravity 和 位置記錄下來。
為什麼要把角色的速度先記錄在變數中,而非直接套用到 Rigidbody 上?
如果更改 Component 中的參數,會即時影響該 GameObject 和相關的東西,特別是 Rigidbody 的影響更大。既然有五件事情要對 velocity 作出影響,先記錄在變數中,之後才對 Rigidbody 作出修改;而且,五個動作都有互相干擾的問題,減少不必要和不常規的問題,也應該先計算好本次循環所需要的變量後,才套用在 Rigidbody 上。


。CheckGround() 檢查 - 地面
    private void CheckGround()
    {
        // Check grounded and height
        Vector3 pos = RayPos.y(RayPos.y + colliderSkin);
        Ray rayDown = new Ray(pos, Vector3.down);
        Physics.Raycast(rayDown, out RaycastHit hit, 10f, groundLayer);
        inAirHeight = hit.transform ?
            hit.distance.ToZero(0.0001f, false) : 10f;

        // Check touching ground
        List colGround = new List();
        List colRebound = new List();
        pos = RayPos.y(RayPos.y + radius * 0.95f);
        LayerMask combineMask = groundLayer | reboundLayer;
        foreach (Collider col in Physics.OverlapSphere(pos, radius * 0.98f, combineMask))
        {
            if (!exclusedFromCollision.Contains(col.gameObject)) {
                if (!colGround.Contains(col.transform) &&
                    groundLayer.Contains(col.gameObject.layer))
                    colGround.Add(col.transform);

                if (!colRebound.Contains(col.transform) &&
                    reboundLayer.Contains(col.gameObject.layer))
                    colRebound.Add(col.transform);
            }
        }
        isStandingOnSomething = colGround.Count > 0;
        isGrounded = inAirHeight < 0.30f || isStandingOnSomething;
        makeRebound = !isGrounded && colRebound.Count > 0;
    }
  • 在角色底部稍高位置向下射出一條長度 10 的 ray 偵測 groundLayer,偵測到的第一個 ground 的距離記錄在 (float)inAirHeight 中。
  • 在角色底部高出角色半徑 95% 的地方做一個角色半徑 98% 的 OverlapShpere,偵測 groundLayer + reboundLayer,把 exclusedFromCollision 以外的東西記錄在 List 中,groundLayer 的 transform 記錄在 colGround,reboundLayer 的 transfomr 記錄在 colRebound 中。
    OverlapShpere 的位置和大小,正好可以偵測角色底部大部份角度但不包括角色碰撞器的邊緣,即是角色必須有踏上對象的上面一部份才會偵測成功,可以避免水平碰撞偵測但保留下方偵測。
  • 如果 colGround 有記錄,isStandingOnSomething = true。
  • 如果 inAirHeight 大過 0.3f 或 isStandingOnSomething 的話,isGrounded = true。
  • 如果 colRebound 中有記錄而不是著地 isGrounded 的話,makeRebound = true,這代表了角色踏上了會反彈角色的東西上。


。CheckPlatform() 檢查 - 腳下平台
    private void CheckPlatform()
    {
        Vector3 pos = RayPos.y(RayPos.y + colliderSkin);
        Ray rayDown = new Ray(pos, Vector3.down);
        Physics.Raycast(rayDown, out RaycastHit hit, 0.20f, groundLayer);
        platformAngle = hit.transform ?
            Vector3.Angle(hit.normal, Vector3.up) : 0;
    }
  • 在角色底部稍高位置向下射出長度 0.2f 的 ray 偵測 groundLayer,計算第一件偵測到的 ground 的角度,並記錄在 platformAngle。


。CheckSlope() 檢查 - 上坡
    private void CheckSlope()
    {
        Vector3 pos = RayPos.y(RayPos.y);
        Ray rayFront = new Ray(pos, transform.forward.normalized * MoveRatio);
        Physics.Raycast(rayFront, out RaycastHit hit, radius, groundLayer);
        slopeAngle = hit.transform ?
            Vector3.Angle(hit.normal, Vector3.up) :
            0;
    }
  • 在角色底部向前射出以角色速度比率為長度的 ray 偵測 groundLayer,計算第一件偵側到的 ground 的角度。
  • 以速度比率為長度可以避免偵測距離過長,令角色提早為上坡作重力調整。
  • 如沒有 MoveRatio 的話,請以 radius 取代 MoveRatio。


。MoveRotate(ref Vector3 velocity) 重力調整 -  移動和轉動
    private void MoveRotate(ref Vector3 velocity)
    {
        velocity = (moveVelocity *
                JumpDecelerateRate *
                Time.fixedDeltaTime).y(rb.velocity.y);

                rb.rotation = Quaternion.Slerp(rb.rotation,
                    rotation,
                    rotateSpeed * Time.fixedDeltaTime);
                rb.rotation = Quaternion.Euler(0, rb.rotation.eulerAngles.y, 0);
    }
  • 第一個重力調整,直接把 velocity 的 x 和 z 取代成 moveVelocity 的 x 和 z,y 則是現在的 Rigidbody.velocity.y。
  • Rotation 並沒有影響什麼移動的要素,所以直接對 Rigidbody 進行修正,因為之後修正也好,都必須和 velocity 分開修正。


。Rebound(ref Vector3 velocity) 重力調整 -  反彈
    private void Rebound(ref Vector3 velocity)
    {
        if (makeRebound)
        {
            isJumping = false;
            isRebounding = true;
        }
        if (isRebounding)
        {
            velocity = (-transform.forward.normalized *
                cb.cInfo.reboundPower *
                Time.fixedDeltaTime).y(rb.velocity.y);
        }
    }
  • 如果角色已踏上 reboundLayer 中的物件時,反彈指令 makeRebound 就是生效 true,終止跳躍行為,同時會開始反彈 isRebounding = true。
  • 當 isRebounding = true,代表會進行反彈,這裡會複寫 MoveRotate() 的設定,讓角色向後方以 reboundPower 移動,velocity.y 等於 rigidbody.velocity.y。
  • 如果角色在空中,會在 Falling() 中被重力疊加拉向下方。


。Climbing(ref Vector3 velocity) 重力調整 -  爬坡
    private void Climbing(ref Vector3 velocity)
    {
        bool notClimb = isJumping || isRebounding;

        float moveDistance = Vector3.Distance(velocity.y(0), Vector3.zero);
        if (slopeAngle > 0 && !notClimb)
        {
            if (slopeAngle <= maxSlope)
            {
                isClimbing = isStandingOnSomething;
                if (isStandingOnSomething)
                    velocity = velocity.y(
                        Mathf.Sin(slopeAngle * Mathf.Deg2Rad) * moveDistance);
            }
            else
            {
                isClimbing = false;
                rb.position = oldPosition;
                velocity = velocity.y(0f, 0f);
            }
        }
        else
        {
            isClimbing = notClimb ? false : platformAngle > 0;
        }
    }
  • 以是否跳躍中和反彈中來決定是否可以爬坡,避免爬坡的重力調整對跳躍和反彈作出影響。
  • 如果上坡角度大於零的話,以角度是否大於最大爬坡上限來決定重力修正的方向,如果大於上限的話,就複寫 MoveRotate() 的設定但保留 y 的值,相反角度在上限以內,就以本循環移動的距離來調整向上的逆重力,讓角色可以流暢爬坡。
  • 如果上坡角度不是大於零,也就是前方沒有上坡的話,就以是否踏著上坡來設定 isClimbing 狀態。


。Jump(ref Vector3 velocity) 重力調整 - 跳躍
    private void Jump(ref Vector3 velocity)
    {
        if (isRebounding) return;

        if (makeJump)
        {
            isJumping = true;
            jumpRatio = MoveRatio.Remap(0, 1, 1, moveJumpRatio);
            velocity.y =MaxJumpVelocity;
        }
        if (minJump)
        {
            velocity.y = MinJumpVelocity;
            minJump = false;
        }
    }
  • 如果正在反彈中,則什麼也不做,避免跳躍的重力調整對反彈作出影響。
  • 如果外部傳入了起跳指令,設定狀態 isJumping = true,以移動速度比率和移動速度跳躍高度比率來計算跳躍比率 (float)jumpRatio,再把 MaxJumpVelocity 套在 velocity.y 上。
  • 如果沒有 MoveRatio 來計算,或不打算讓跳躍力和速度掛鉤的話,直接把 jumpRatio 設成 1 就可以。
  • 如果外部傳入不跳到最大跳躍高度的話,把 MinJumpVelocity 套在 velocity.y 上,並把 minJump 的指令取消。


。Falling(ref Vector3 velocity) 重力調整 - 下墮
    private void Falling(ref Vector3 velocity)
    {
        if (!isStandingOnSomething)
        {
            isFalling = !isGrounded && lastGravity < 0;
            velocity += Vector3.up * -Gravity * Time.fixedDeltaTime;
        }
        else
        {
            isFalling = false;
            if (isJumping && !makeJump)
                isJumping = false;
            if (isRebounding && !makeRebound)
                isRebounding = false;
        }
        makeJump = makeRebound = false;
    }
  • 以有沒有踏著什麼來決定進行重力調整。
  • 當沒有踏著什麼時,設定 isFalling 的狀態,並向角色以 Gravity 施以向下的加速力。
  • 當踏著什麼時,即是著地時,設定 isFalling 的狀態。
  • 在著地時,跳躍和反彈的狀態要在進行中和起始指令不在時才會取消,這是因為在起跳指令 makeJump 和反彈指令 makeRebound 生效時,角色其實還踏著什麼的狀態,所以上面進行跳躍和反彈時都不會取消指令,避免在下墮時把進行中的指令取消。
  • 無論什麼狀況都到,最後會把起跳和反彈指令取消。

。rb.velocity = velocity 套用
  • 把是次 Physics Cycle 的重力調整套用在 Rigidbody.velocity 中。


<Extension Methods Class>

在程序中需要用到本人的 Extension Methods Class,如果之前有下本人的 System Class 的話,可以刪除,用這個來取代。



後話

感覺整理這篇,要比寫這個 Custom Physics 要痛苦,因為要把自己不同架構的東西放回在這裡,只是檢查也花了不少時間,還要做測試影片,最後檢查有沒有什麼錯誤也花不少時間。。。

我應該還會繼續為這個自訂物理加上更多的重力調整,以配合更多不同情況,但應該不會公開的,因為已到了一些遊戲戰鬥核心的東西,不過相信只看以上這些,都可以再開發更多的調整。

非常感謝閣下看到這裡,如果有想要本人分享的開發部份,可以留言,有時間的話我會處理。

留言

此網誌的熱門文章

[教學]一起來開發遊戲吧 - Unity C# 基礎

QUMARION

[教學]一起來開發遊戲吧(二) - Character Controller, Pool System