前言
本篇主角:https://github.com/NoelFB/Celeste/
我一直都對 2D Platformer 的製作很感興趣,這陣子通關《Celeste》後給了我一股動力,我非常喜歡這款遊戲帶給我的體驗。在查詢相關資訊時,我偶然發現開發團隊居然有釋出code,我決定來好好研究一番!
在剛開始閱讀時,我想起了《Dead Cells》設計師Deepnight去年在TGDF的演講,他建議一款Platformer應該要避免使用內建的物理引擎,這對精準控制角色是一件極為不利的事情。而《Celeste》的設計應證了這句話。
如裡頭的說明文件所述,雖然《Celeste》的基礎物理系統並沒有釋出,但運作邏輯與團隊的前作《Tower Fall》非常相似,可以參考Matt Thorson之前寫的文章。
這篇文章裡解釋了很重要的兩個Base Class:Actor和 Solid。會移動的物體繼承Actor、所有平台則繼承Solid。
這樣一份整理出來的東西難免有些破碎,我會標示出程式碼所在的行數,希望能多少幫助到對這類型遊戲同樣有興趣的人!
p.s 《Celeste》不是用Unity開發的,但程式語言一樣是C#
狀態機
遊戲進行的過程中,角色會經歷許多不同的狀態:攀牆、衝刺、游泳…等。每一種狀態下的操作機制都不太相同,為了方便管理,所以每一個State都有屬於自己的Begin, Update, End函式。
Begin:進入此State時被執行的函式。
Update:處在此State裡的每一幀都會被執行的函式。
End:離開此State前被執行的函式。
這裡提到的Begin, Update, End只是大概念,和Unity內建的基礎Update()是指不一樣的東西。例如NormalUpdate()就是屬於Normal State的Update函式。
這樣針對不同State客製Update的優點是:最基礎的Update()只負責寫一些所有State都會需要的系統,像是蒐集草莓、冷卻時間的倒數。至於其他特殊State裡的機制,可以在專屬於自己的function裡實作。這個架構就是Design Pattern的 State – 狀態模式。
用來記錄當前狀態的變數是StateMachine.State,它是一個int,在StateMachine.cs裡會根據這個int的值去判斷目前要執行哪一個Update函式。stNormal, stDash這些變數其實都是int,只是為了容易辨識而特別取的名字,定義寫在Line 140。這樣的編排讓我想起了寫OpenGL的恐懼(?)
範例:在飛行狀態下可以八方位移動、不受重力影響、不能跳和衝刺…這些規則只要寫在Line4476 starFlyUpdate()裡即可,其他State不需要知道這些事情,不用為了這個特殊案例而在Update()裡面寫一大堆if-else。

SetCallBacks這個函式接收的參數內容為:
(int 狀態編號, int Update函式, Ienumerator 協程, void Start函式, void End函式)
p.s 有些State不需要時時更新、有些則不需要Ienumerator的存在,那就寫null即可。
每個狀態所屬的Update函式都有個回傳值int,如果回傳的不是目前所在的狀態編號,那就代表準備要切換到其他狀態。
範例:在Line 2805,如果玩家在Normal狀態下輸入攀牆的指令且符合執行條件,那就回傳StClimb。此時,根據綁定好的CallBack,執行順序會是:
NormalEnd() -> ClimbBegin() -> ClimbUpdate() -> ClimbUpdate() -> ClimbUpdate() …
碰撞偵測
主角在畫面上的形態有時會改變(ex.攀牆、變身),此時判定碰撞的hitbox也會跟著切換,這部分的數值設定寫在 Line 263,用一個叫做Collider的Hitbox型別變數來記錄「主角可以和距離多近的物件產生互動」。範例:Line 4361,主角吃到了道具而進入飛行模式,這時Collider就要切換成”starFlyHitbox”。
private readonly Hitbox starFlyHitbox = new Hitbox(8, 8, -4, -10);
雖然檔案裡沒有明說,但我認為Hitbox參數應該是(寬度,高度,左下頂點的x值,左下頂點的y值)
雖然(右上頂點x, 右上頂點y, 左下頂點x, 左下頂點y)聽起來也可以
但我覺得不太需要這樣設計XD
單位是pixel,座標原點則是角色的中心。
不同於《TowerFall》,《Celeste》裡的Actor.cs的function命名MoveX改成了MoveH、MoveY改成了MoveV,但依然秉持著水平位移、垂直位移分開處理的原則,並且夾帶一個型態為Action的參數。以垂直位移為例,每次執行Update()時,Line 935的MoveV就會根據Speed.Y去進行實際的位移,同時在參數中夾帶自己的onCollideV過去。
(註:Action是System函式庫裡的delegate委派物件,可以把自己寫的function註冊在它底下,每當這個Action被執行時,就會一併執行有登記在他名下的function。)
如果Player的Base Class – Actor判定目的地是可以前往的,那就直接把物件移動過去,大家相安無事。反之,如果該座標有障礙物而導致碰撞,那就要執行事先傳進來的這個onCollideV,由它來決策該如何處理。
onCollideV從 Line 2453開始,前面先針對不同State來進行數值的初始化和善後,接下來的一個大重點是在Line 2629:如果碰撞到的物件也帶有Action,那就執行它(叫你們經理出來!)。遊戲中「收集草莓」、「碎石平台被踩到就會裂開」等事件就是從這裡被觸發。

onCollideV接收的參數型態是CollisionData,這個class好像蠻有趣的!雖然不清楚裡面實際存有哪些資訊,但可以確定的是:所有會和主角產生碰撞的物體都必須擁有這個class。
Timer
這份Code大量使用了計時器的概念,處理各種行為的持續時間、冷卻時間…等。由於原理都大同小異,在這裡我就用「Normal狀態下的衝刺冷卻時間 – dashCooldownTimer」來舉例。(主角在不同State裡按衝刺鍵會有不同的效果,所以在這裡只看Normal State)
首先是Line 2824,如果CanDash這個bool是true,則把衝刺的力道傳給Speed,並且呼叫StartDash()這個函式。CanDash的定義在Line 3377,回傳true的條件有4個:
1.玩家輸入Dash指令
2.衝刺的冷卻計時器 dashCooldownTimer <=0 (不是在冷卻狀態)
3.可使用的衝刺數量 Dashes>1 (遊戲後期可能會有二段…可能啦)
4.不是在對話狀態下 (其實放在這好像怪怪的,但這不是討論的重點XD)
如果以上條件都成立,將會呼叫Line 3358的StartDash()並且把狀態機編號切換成StDash。
進入衝刺狀態後,衝刺狀態的Start函式在Line 3451把dashCooldownTimer的值改為DashCooldown,即0.2秒。這代表不管現在可使用的衝刺數量有多少,都必須經過0.2秒的間隔才能進行下一次施放。
這個Timer會在Line 725隨著引擎的DealtaTime逐漸扣到0,如此一來上述CanDash的第2項條件又再次被滿足了,這就是一個最基本的Timer運作循環。
問題探討1 – Speed變數的使用
在剛開始讀這份Code時,我對於”Speed”這個變數感到很困惑。所有的位移行為(走路、跳躍、衝刺、被移動平台載著走…)都不負責直接產生移動,而是把變化量塞給Speed,再由MoveH、MoveV兩個函式去實際進行位移(Line 935)。整份檔案裡面,針對Speed.X、Speed.Y做修改的Code總共有一百多處,而且幾乎都是直接用等號Assign新的值進去。到底該如何確保各個動作產生的Speed變化量不會互相覆蓋呢?
我想,這部份要歸功於《Celeste》的State Machine劃分。為了避免資訊錯亂,每一次執行State專屬的Update函式時,只會改動Speed的值一次。以攀爬狀態為例,Line 3111代表的是「玩家在攀牆狀態下按了跳躍」,這時會根據玩家是朝著牆壁跳還是想要跳離牆壁,進而改動Speed。這個Speed會被Update()拿去做處理,而此時ClimbUpdate呼叫了return。
我覺得呼叫return是我剛開始沒有想通的重點之一,它讓Update的code不要從頭執行到尾,因為他已經受理了Speed的變化,必須先讓MoveH、MoveV去產生實質改變後才能回來繼續等候命令,如果每次都讓Update()跑好跑滿,後面發生的改動就一定會把前面的覆蓋掉。
所以,雖然每一個State的Update函式都看似很複雜,但是在《Celeste》這種必須很頻繁下達操作指令的遊戲中,Update常常會在中途就被打斷,並沒有從頭執行到尾。
舉例來說,在遊玩《Celeste》時,對玩家來說的「蹬牆跳躍」流程如下:
1.處於攀附牆壁的狀態下
2.方向鍵壓住與目前的牆壁相反的方向
3.等待時機按下跳躍
對系統來說的流程則如下:
1.循環執行位於Line 3102的ClimbUpdate(),如果沒接收到什麼特別的指令,那就處理動畫圖輪播、消耗體力這些例行公事。
2.偵測到玩家蹬牆跳的需求,呼叫WallJump(),給予Speed一個變化量
3.因為此時已經沒有攀著牆壁了,回傳State編號StNormal,讓狀態機回到Normal狀態
如果玩家蹬牆跳結束後,又想要再攀附下一面牆壁,那就由NormalUpdate()來判斷是否符合條件,可以的話就讓State編號再次回到StClimb。
問題探討2-碰撞偵測的實作細節?
由於這份code和《TowerFall》那篇文章裡並沒有提到該如何判斷「即將撞到其他物件」,以下是我根據code推測出的一些蛛絲馬跡。在Line 993中,程式想要確認在主角的碰撞範圍內是否有需要被觸發(Trigger)的物件,經歷了以下流程:
1.從Scene.Tracker取出所有”Trigger”類別的實體
2.逐一確認他們是否有和主角發生碰撞
3.如果有碰撞且這個Trigger還沒被觸發過,那就進行觸發事件
雖然「取出所有Trigger類別的實體逐一做檢查」聽起來很消耗資源,但一般來說,一個關卡內所擺放的物件總量不太可能多到危害效能,這的確是最可靠的一種方式。
在TowerFall Physics那篇文章中曾經提到,所有的collider都採用AABB碰撞檢測(Axis-aligned Bounding Box),所以要找出誰有機會和主角發生碰撞相當容易,根據物件的座標來判斷它是否位在主角的Hitbox內即可。
除了這種抓取所有物件位置的偵測方法,這個檔案裡還使用了另一種主動偵測的方法。
在Line 2593寫著 if (!CollideCheck<Solid>(Position + new Vector2(-i, -1)))
意思是「如果在Position + new Vector2(-i, -1)))這個位置沒有Solid這類型的物件」
所以用法就是 CollideCheck<物件類型>(搜尋的座標)
如果目標座標上存在所要尋找的目標,則回傳true。
對於《Celeste》這種解析度320×180的Pixel-Art Game來說,要判斷「第幾個像素點上是否存在特定物件」並非難事,而且比起使用射線更簡單、可靠,但未必適用於所有遊戲類型,這部分就需要針對專案性質自行斟酌了。

