iT邦幫忙

2025 iThome 鐵人賽

DAY 19
1
Modern Web

用 PixiJS 寫遊戲!告別繁瑣設定,在 Code.Gamelet 打造你的第一個遊戲系列 第 19

Day 19:專案架構升級!實作 Game 類別,告別混亂的 start()

  • 分享至 

  • xImage
  •  

昨天我們為小女巫加入了自動攻擊能力,但正如我們在總結中預見的,start() 函數已經開始變得過於臃腫:它既要負責初始化背景,又要管理女巫的事件,甚至還要管理圖層、子彈物件等。

若我們繼續在這樣的架構之下添加新功能,例如敵人、碰撞傷害、UI 等等,之後它就會肥到走不動!到時候再來減肥就已經為時已晚了。

因此今天的核心目標就是透過實作 Game 類別,來扮演遊戲的中央控制器,讓每個物件各司其職,實現清晰的職責分離。

▸ 資料結構

之前我們都是在 ironman2025_cook 這個專案上開發,為了分開不同天數的程式碼。從今天開始,為了專案的整潔與可維護性,我另外新創了 LittleWitch_TheJourney 專案,專門用來開發《小女巫・啟程》這個遊戲的,但我也會同步更新 ironman2025_cook 的專案,可以看到某一天的程式碼。

接著,我事先把專案的資料結構定義好了:
小女巫專案 資料結構

除了 Game 類別以外,我也將前幾天的 ParallaxBackgroundWitchMagicBullet,全部獨立成各自的檔案。一個獨立的類別就獨立成一個檔案,應該算是大部分人的習慣。

接下來我們就來一個個看看我是怎麼把它們給分開的吧!

▸ 遊戲中央控制器:Game 類別

我們的 Game 類別,同樣會繼承自 PIXI.Container,它被視為整個遊戲的根節點。

import pixi = CG.Pixi.pixi;
import { ParallaxBackground } from './Display/ParallaxBackground';
import { Witch } from './Characters/Witch';
import { MagicBullet, IMagicBulletData } from './Effects/MagicBullet';

export class Game extends PIXI.Container {

	// 視差背景
	private _parallaxBackground: ParallaxBackground;
	// 角色圖層
	private _characterLayer: PIXI.Container;
	// 特效圖層
	private _effectLayer: PIXI.Container;

	// 小女巫
	private _witch: Witch;

	constructor() {
		super();

		// 創建視差背景實例,並加入到舞台最底層。
		const parallaxBackground = this._parallaxBackground = new ParallaxBackground();
		this.addChild(parallaxBackground);

		// 特效圖層(用於加入各種場景特效,如魔法彈、敵人技能等)
		const effectLayer = this._effectLayer = new PIXI.Container();
		this.addChild(effectLayer);

		// 角色圖層(用於加入各種角色,如小女巫、敵人)
		const characterLayer = this._characterLayer = new PIXI.Container();
		this.addChild(characterLayer);

		// 創建小女巫實例,並初始化位置等。
		const witch = this._witch = new Witch();
		witch.position.set(pixi.stageWidth * 0.25, pixi.stageHeight * 0.5);
		characterLayer.addChild(witch);

		// 接收小女巫攻擊事件,生成魔法彈並加入特效圖層
		witch.on(Witch.EVENT.ATTACK, (data: IMagicBulletData) => {
			const bullet = new MagicBullet(data);
			effectLayer.addChild(bullet);
		}, this);

		// 設置更新循環函數,每一幀都呼叫 update 函數
		CG.Base2.addUpdateFunction(this, this._update);

	}

	/**
	 * 更新循環函數。
	 * @param dt - 每幀間隔時間(ms)
	 */
	private _update(dt: number): void {

		this._parallaxBackground.update(dt);

		// 歷遍特效圖層與角色圖層的所有子物件,以執行 update()
		for (const layer of [this._effectLayer, this._characterLayer]) {
			const children = layer.children;
			for (let i = children.length - 1; i >= 0; --i) {
				const gameObject = children[i];
				gameObject["update"](dt);
			}
		}
	}
}
  • import:由於我們將所有的類別拆分到各自的檔案去了,因此需要使用 import 來將它們引入進來,不過 CG 在我們使用這些類別的時候,就會自動幫我們做這件事情了。
  • export:想要讓其他檔案可以 import 這個類別,就必須在宣告時加上 export 關鍵字,才可以被其他檔案引入,其他的 ParallaxBackgroundWitch 等也要喔!
  • 圖層管理:創建並維護各個視覺圖層(背景、角色、特效)。
  • 物件初始化:實例化主角、背景等物件,並設定它們之間的事件監聽。
  • 統一更新循環:在一個 _update(dt) 函數中,集中呼叫所有遊戲物件的 update(dt) 函數。

gameObject["update"](dt) 這裡的 update 使用 [""] 來讀取是為了避免 TypeScript 跳出警訊,因為它只知道 children 裡面裝的是 PIXI.ContainerChild[](容器子物件陣列),但這個物件本身並沒有 update 屬性或函數,因此會跳出警訊。而我們是通過前後文知道裡面的子物件都會有 update 函數,因此可以先暫時以此方式應急,這個部分應該會在實作敵人或碰撞傷害的那天做調整,暫時先稍微知道就好。

app.ts 專案進入點

原先複雜的 start() 函數現在被極度簡化,它只負責載入資源和啟動單一的 Game 實例。

import pixi = CG.Pixi.pixi;
import { Game } from './Games/Game';

async function start() {

	// 將專案資源載入至遊戲中,並等待載入完成。(記得將資源別名修改成你自己專案的資源別名喔!)
	await pixi.assets
		.add("LittleWitch_TheJourney.圖片.背景")
		.add("LittleWitch_TheJourney.圖片.遠景")
		.add("LittleWitch_TheJourney.圖片.前景")
		.add("LittleWitch_TheJourney.圖集動畫.角色")
		.add("LittleWitch_TheJourney.圖集動畫.特效")
		.load();

	// 初始化 Pixi。(啟用 stageMask 可以讓舞台以外的地方不會被顯示)
	await pixi.initialize({ stageWidth: 960, stageHeight: 540, stageMask: true });

	// 創建遊戲實例並加入舞台中。
	const game = new Game();
	pixi.root.addChild(game);
}

start();
  • stageMask:我這次在 pixi.initialize 偷偷加了 stageMask: true(舞台遮罩),它可以讓畫面只顯示舞台大小範圍內的東西。因為在昨天加入子彈之後,子彈是可以飛出舞台外的,沒有啟用的話就會看到子彈飛出邊界一點點。

其他東西都被移入 Game 了,因此可以全部刪除,以保留 start() 的整潔。

▸ 其他類別調整

由於獨立在各自的檔案,因此所有的類別都需要調整 import,但其他部分就都一模一樣了。
當然還有因為切換專案,所以資源別名也需要調整,但這個我就不特別一一寫出來了。

一、ParallaxBackground(視差背景)

import pixi = CG.Pixi.pixi;

export class ParallaxBackground extends PIXI.Container {

	// ... (其餘不變)
    
}

二、Witch(小女巫)

import pixi = CG.Pixi.pixi;
import keyboard = CG.Base2.keyboard;
import Key = CG.Base2.keyboards.Key;
import { IMagicBulletData } from './../Effects/MagicBullet';

export class Witch extends PIXI.Container {

	// ... (其餘不變)

}

三、MagicBullet(魔法彈)

import pixi = CG.Pixi.pixi;

// ... (其餘不變)

export class MagicBullet extends PIXI.Container {

	// ... (其餘不變)
    
}

怎麼樣,把每個類別都各自獨立成一個檔案以後,是不是看起來就乾淨許多了?整個人都舒爽了起來~

點我查看 Day 19 範例程式碼點我查看最新進度程式碼

▸ 總結

我們今天透過實作 Game 類別,完成了專案最重要的架構重構。現在專案的職責非常明確:

  • app.ts:負責載入資源與啟動遊戲。
  • Game.ts:負責圖層管理、物件生命週期(創建、事件監聽)和統一更新。
  • Witch.ts / MagicBullet.ts / ParallaxBackground.ts:只負責各自物件的內部邏輯(移動、計時、銷毀)。

其實原本第二階段的一開始,Day 16 開始進入寫遊戲的階段時,我就在想要寫一個 Game 類別,但後來考慮到一開始就在寫這些畫面上看不到的東西,對於讀者來說可能會太無趣,因此便決定把最具視覺效果的視差卷軸背景放在第一天,之後再慢慢添加小女巫、魔法彈。除了讓一天的文章內容資訊量不會太過龐大,各位不好吸收以外,也剛好讓各位看到有無 Game 的前後對比,更能了解為什麼要這樣子去規劃專案架構。

有了這個穩定的架構,明天我們就可以放心地往上疊加遊戲內容。

既然小女巫已經有了穩固的飛行器和強大的火力,那麼明天就該有東西讓她打了!因此我們要來實作敵人生成系統,讓我們的第一個敵人正式從畫面右側登場!


上一篇
Day 18:自動攻擊 - 發射第一顆魔法彈
下一篇
Day 20:敵人登場!實作敵人生成系統與基礎敵人類別
系列文
用 PixiJS 寫遊戲!告別繁瑣設定,在 Code.Gamelet 打造你的第一個遊戲23
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言