iT邦幫忙

2025 iThome 鐵人賽

DAY 10
1

在前面幾天,我們已經不知不覺地使用了 CG.Base2.addUpdateFunction 這個功能好幾次。無論是讓 Sprite 像螢幕保護程式一樣在畫面中彈來彈去(Day 04),還是利用 dt 屬性來累積時間以切換紅綠燈號(Day 06),甚至是實現玩家用鍵盤控制物件移動(Day 09),這些功能的背後,都離不開一個遊戲中最核心的概念——遊戲主迴圈

今天,我們將正式深入了解這個遊戲的「心跳」,以及為什麼它如此重要。

▸ 什麼是遊戲主迴圈(Game Loop)?

你可以把遊戲主迴圈想像成一個永不停歇的循環,它在遊戲啟動後就不斷重複執行。這個迴圈是遊戲的核心,它提供了一個永不停止的環境,讓我們能夠在每一幀中執行以下這些遊戲邏輯:

  • 處理輸入(Input):偵測玩家的鍵盤、滑鼠或觸控操作。
  • 更新遊戲狀態(Update):根據輸入和遊戲邏輯,計算物件的新位置、檢查碰撞、更新分數等。
  • 渲染畫面(Render):將更新後的物件重新繪製在螢幕上。

這個迴圈運行的速度,就是我們常聽到的「畫面更新率、幀率(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,並在迴圈函數內乘上 dtdt 的單位是毫秒,因此實際的移動距離 distance 就能得到上次更新畫面到現在,角色應該移動多少距離。

但老實說只有這樣也看不出差異,因此接下來再看看下面這個。

▸ 不同 FPS 有無使用 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 用途相同即可。

不同 FPS 遊戲主迴圈有無使用 dt 差異演示

點我查看範例程式碼

▸ 總結

今天我們深入了解了遊戲的心臟——遊戲主迴圈,並學會了如何正確地使用 CG.Base2.addUpdateFunction()dt 參數來實現穩定、流暢的遊戲邏輯。

當然這只是其中一個功能,實際上關於這方面的概念是很廣的。例如你有一個用來控制所有遊戲流程的 Game 類別,你將裡面的 update() 函數註冊進主迴圈中,並在這個 update() 去更新底下的所有物件,例如玩家、敵人、正在飛行的子彈、動畫等,整個遊戲的更新系統就會像是一個樹狀圖一樣,從這個 Game 底下散開。如果 Game 有一個 paused 的屬性,你就可以在 update() 最一開始檢查 if(paused),通過時就直接 return,來達到遊戲暫停的效果,這才是遊戲主迴圈的意義,它更像是一個流程控制系統。

不過這方面的應用太多了,我沒辦法在這裡一一介紹出來,等我們後面開始實作遊戲專案的時候,有碰到的話再來慢慢細說,今天就針對 dt 這個參數的概念來介紹就好。

Day 04 時我們讓 Sprite 跟著迴圈不斷更新位置,使其動起來,但如果我們只是想要讓他飛行一段距離呢?例如從畫面的左邊移動到右邊,又或是我們之後想要讓某個視窗從小慢慢放大,實作視窗的開啟動畫呢?雖然循環更新函數也可以做到,但是會複雜許多,因為我們必須自己記錄顯示物件一開始的狀態、動畫時間、結束的狀態等。為了更好的實現這種功能,明天我們要來介紹一個能夠與 PixiJS 互相搭配的好用工具——補間動畫(Tween)


上一篇
Day 09:處理玩家輸入 - 鍵盤事件的偵測
下一篇
Day 11:物件的平滑移動 - 使用補間動畫 (Tween)
系列文
用 PixiJS 寫遊戲!告別繁瑣設定,在 Code.Gamelet 打造你的第一個遊戲12
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言