遊戲開發日誌 #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 差不多,如果是高速角色,其重力便要加大更多才能夠消除飄浮感。
當然影片中的高速只是測試用,並不會用在遊戲中。
在影片的後段,把角色的移動速度加大,但跳躍的參數不改變,就會發現角色在跳躍時的飄浮感很強,這就是上一篇所說的問題。
現在這個測試角色並不是高速角色,所以重力的調整並不用太強,現實重力的加速力是 9.81 m/s^2,現在這個角色的反計算重力是 12.5 m/s^2,跟 Mario 差不多,如果是高速角色,其重力便要加大更多才能夠消除飄浮感。
當然影片中的高速只是測試用,並不會用在遊戲中。
注1:在影片中是本人的遊戲實作,所以架構是不同的,下面的程序都把所需的東西放回這個 class 中。
注2:由於是本人的遊戲設定,如有不需要的地方,請自行修正或移除,我會盡力去說明如何修改或移除的。
注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 要痛苦,因為要把自己不同架構的東西放回在這裡,只是檢查也花了不少時間,還要做測試影片,最後檢查有沒有什麼錯誤也花不少時間。。。
我應該還會繼續為這個自訂物理加上更多的重力調整,以配合更多不同情況,但應該不會公開的,因為已到了一些遊戲戰鬥核心的東西,不過相信只看以上這些,都可以再開發更多的調整。
非常感謝閣下看到這裡,如果有想要本人分享的開發部份,可以留言,有時間的話我會處理。
留言