在前面幾天,我們已經不知不覺地使用了 CG.Base2.addUpdateFunction
這個功能好幾次。無論是讓 Sprite
像螢幕保護程式一樣在畫面中彈來彈去(Day 04),還是利用 dt
屬性來累積時間以切換紅綠燈號(Day 06),甚至是實現玩家用鍵盤控制物件移動(Day 09),這些功能的背後,都離不開一個遊戲中最核心的概念——遊戲主迴圈。
今天,我們將正式深入了解這個遊戲的「心跳」,以及為什麼它如此重要。
你可以把遊戲主迴圈想像成一個永不停歇的循環,它在遊戲啟動後就不斷重複執行。這個迴圈是遊戲的核心,它提供了一個永不停止的環境,讓我們能夠在每一幀中執行以下這些遊戲邏輯:
這個迴圈運行的速度,就是我們常聽到的「畫面更新率、幀率(FPS)」。如果你的遊戲幀率是 60 FPS,就代表這個迴圈每秒會執行 60 次。
在 Code.Gamelet 上,我們不需要自己手動去創建這個複雜的迴圈,因為 CG.Base2
已經為我們準備好了。我們只需要使用 CG.Base2.addUpdateFunction()
,就能將自定義的程式碼「註冊」到這個遊戲主迴圈中,讓它自動在每一幀被執行。
dt
(Delta Time)?在 Day 09 我們用鍵盤輸入讓物件動了起來,一個直觀的想法是,我們可以讓角色在每一幀中都移動一個固定的值,例如:
// 簡單移動,但會受 FPS 影響
CG.Base2.addUpdateFunction(() => {
// 角色每幀移動 5 像素
character.x += 5;
});
然而,這種寫法會讓遊戲在不同裝置上運行速度不一致。在一台效能好的電腦上,如果 FPS 是 60,那麼角色每秒會移動 5 * 60 = 300
像素;但在效能較差的手機上,如果 FPS 只有 30,那麼角色每秒只會移動 5 * 30 = 150
像素。
那麼,要如何讓角色的移動速度,無論在什麼裝置上都能保持一致呢?
為了解決這個問題,系統每次呼叫我們由 CG.Base2.addUpdateFunction()
傳入的函數時,都會帶入一個 dt
參數,它代表的是「自上一幀到現在所經過的時間(毫秒)」。
有了 dt
,我們就可以讓所有物件的移動都與時間掛鉤,而不是與幀率掛鉤。
// 修正後的移動,不受 FPS 影響
const speed = 300; // 每秒移動 300 像素
CG.Base2.addUpdateFunction((dt: number) => {
character.x += speed * (dt / 1000); // 將毫秒轉換為秒
});
如此一來,無論 FPS 是 60 還是 30,角色每秒都會精準地移動 300 像素。這就是 dt
的精髓所在,也是實現跨平台遊戲一致性體驗的關鍵。
現在,讓我們將今天學到的 dt
和昨天的鍵盤偵測結合在一起,實現一個能用鍵盤控制且移動速度穩定的角色。
import pixi = CG.Pixi.pixi;
import keyboard = CG.Base2.keyboard;
import Key = CG.Base2.keyboards.Key;
async function start(): Promise<void> {
// 載入資源(記得將資源別名修改成你自己專案的資源別名喔!)
await pixi.assets.add("ironman2025_cook.圖片.女巫").load();
// 初始化 Pixi。(請把 updateRendererOptions 當作 initialize)
await pixi.updateRendererOptions({ stageWidth: 960, stageHeight: 540 });
// 創建並設定角色 Sprite
const character = pixi.assets.createSprite("ironman2025_cook.圖片.女巫");
character.anchor.set(0.5);
character.position.set(pixi.stageWidth * 0.5, pixi.stageHeight * 0.5);
pixi.root.addChild(character);
// 定義移動速度 (每毫秒 0.2 像素)
const moveSpeed = 0.2;
// 將更新函數註冊到遊戲主迴圈
CG.Base2.addUpdateFunction((dt: number) => {
// 計算實際移動距離
const distance = moveSpeed * dt;
// 根據鍵盤狀態,更新角色的位置
if (keyboard.isDown(Key.W)) character.y -= distance;
if (keyboard.isDown(Key.S)) character.y += distance;
if (keyboard.isDown(Key.A)) character.x -= distance;
if (keyboard.isDown(Key.D)) character.x += distance;
});
}
start();
我們將移動速度 moveSpeed
設定為 0.2
,並在迴圈函數內乘上 dt
,dt
的單位是毫秒,因此實際的移動距離 distance
就能得到上次更新畫面到現在,角色應該移動多少距離。
但老實說只有這樣也看不出差異,因此接下來再看看下面這個。
dt
之差異比較為了更直觀的看到不同設備之間,FPS 不同所造成的差異,我稍微做了一個演示範例。
import pixi = CG.Pixi.pixi;
import FPSUpdater = CG.Base2.utils.FPSUpdater;
import keyboard = CG.Base2.keyboard;
import Key = CG.Base2.keyboards.Key;
async function start(): Promise<void> {
// 載入資源(記得將資源別名修改成你自己專案的資源別名喔!)
await pixi.assets.add("ironman2025_cook.圖片.女巫").load();
// 初始化 Pixi。(請把 updateRendererOptions 當作 initialize)
await pixi.initialize({ stageWidth: 960, stageHeight: 540 });
// 定義移動速度 (每毫秒 0.2 像素)
const moveSpeed = 0.2;
for (let i = 0; i < 2; ++i) {
for (let j = 0; j < 2; ++j) {
// 建立測試舞台容器
const stage = new PIXI.Container();
// 設定不同舞台的位置、並縮小加入舞台
if (i === 1) stage.x = pixi.stageWidth * 0.5;
if (j === 1) stage.y = pixi.stageHeight * 0.5;
stage.scale.set(0.5);
pixi.root.addChild(stage);
// 創建並設定角色 Sprite
const character = pixi.assets.createSprite("ironman2025_cook.圖片.女巫");
character.anchor.set(0.5);
character.position.set(pixi.stageWidth * 0.5, pixi.stageHeight * 0.5);
stage.addChild(character); // 將角色加入測試舞台
// 根據舞台建立不同的 FPS 更新器,並設置循環更新函數。
const fps = i === 0 ? 60 : 10;
const updater = new FPSUpdater(fps);
updater.addUpdateFunction(this, (dt: number) => {
// 計算移動距離
const distance = j === 1
? moveSpeed * dt
: moveSpeed * (1000 / 60);
// 根據鍵盤狀態,更新角色的位置
if (keyboard.isDown(Key.W)) character.y -= distance;
if (keyboard.isDown(Key.S)) character.y += distance;
if (keyboard.isDown(Key.A)) character.x -= distance;
if (keyboard.isDown(Key.D)) character.x += distance;
});
// 建立顯示文字
const infoText = new PIXI.Text({
text: `FPS: ${fps}` + (j === 1 ? ",使用 dt" : ""),
style: { fill: 0xFFFFFF, fontSize: 50 },
anchor: { x: 0.5, y: 0 },
position: { x: pixi.stageWidth * 0.5, y: pixi.stageHeight * 0.05 }
} as PIXI.TextOptions);
stage.addChild(infoText);
}
}
// 劃出十字的分隔線
const line = new PIXI.Graphics()
.moveTo(pixi.stageWidth * 0.5, 0).lineTo(pixi.stageWidth * 0.5, pixi.stageHeight)
.moveTo(0, pixi.stageHeight * 0.5).lineTo(pixi.stageWidth, pixi.stageHeight * 0.5)
.stroke(0xFFFFFF);
pixi.root.addChild(line);
}
start(); // 開始執行初始化
上方的程式碼會將畫面分割成四等分,分別創建四個測試場景,左邊的 FPS 為 60,右邊為 10,且僅有下方會使用 dt
參數。
FPSUpdater
:這是一個跟 CG.Base2.addUpdateFunction
用途相同的類別,都有 addUpdateFunction
可以用來加入會循環更新的函數,但我們可以限制其 FPS。詳細的部份我們暫且不深入,這邊只要知道它和 CG.Base2.addUpdateFunction
用途相同即可。今天我們深入了解了遊戲的心臟——遊戲主迴圈,並學會了如何正確地使用 CG.Base2.addUpdateFunction()
和 dt
參數來實現穩定、流暢的遊戲邏輯。
當然這只是其中一個功能,實際上關於這方面的概念是很廣的。例如你有一個用來控制所有遊戲流程的 Game
類別,你將裡面的 update()
函數註冊進主迴圈中,並在這個 update()
去更新底下的所有物件,例如玩家、敵人、正在飛行的子彈、動畫等,整個遊戲的更新系統就會像是一個樹狀圖一樣,從這個 Game
底下散開。如果 Game
有一個 paused
的屬性,你就可以在 update()
最一開始檢查 if(paused)
,通過時就直接 return
,來達到遊戲暫停的效果,這才是遊戲主迴圈的意義,它更像是一個流程控制系統。
不過這方面的應用太多了,我沒辦法在這裡一一介紹出來,等我們後面開始實作遊戲專案的時候,有碰到的話再來慢慢細說,今天就針對 dt
這個參數的概念來介紹就好。
Day 04 時我們讓 Sprite 跟著迴圈不斷更新位置,使其動起來,但如果我們只是想要讓他飛行一段距離呢?例如從畫面的左邊移動到右邊,又或是我們之後想要讓某個視窗從小慢慢放大,實作視窗的開啟動畫呢?雖然循環更新函數也可以做到,但是會複雜許多,因為我們必須自己記錄顯示物件一開始的狀態、動畫時間、結束的狀態等。為了更好的實現這種功能,明天我們要來介紹一個能夠與 PixiJS 互相搭配的好用工具——補間動畫(Tween)。