iT邦幫忙

2025 iThome 鐵人賽

DAY 20
1
Modern Web

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

Day 20:敵人登場!實作敵人生成系統與基礎敵人類別

  • 分享至 

  • xImage
  •  

昨天我們完成了專案的架構重構,實作了中央控制器 Game 類別。有了這個穩固的基礎,今天的核心目標是實作一個敵人生成系統,讓敵人能夠有規律、有波次地從畫面右側出現,並朝著小女巫移動。

▸ 程式碼架構再升級:遊戲物件繼承體系

在開始製作敵人以前,我們先來解決一個小問題。小女巫、魔法彈,再加上今天的敵人,已經有三個遊戲物件了,這些遊戲物件都有一些共同的屬性、函數,例如 _spriteupdate(dt) 等。如果我們每次新增一個新的遊戲物件,都要重寫這些東西實在是太麻煩了,而且昨天我們也稍微提到了關於 gameObject["update"](dt) 的部分。

總之,現在我們得先來優化這個部分,定義一個統一的類別作為規範,讓所有的遊戲物件繼承這個類別,再去衍生各自的屬性、功能。

我們將會建立幾個基礎類別:

  • GameObject:所有遊戲物件基礎,任何會被放進遊戲場景的物件都應該繼承此類別(如子彈、特效)。
  • BaseCharacter:角色類別的基礎,專門用於角色物件的相關類別(如小女巫、敵人)。
  • BaseEffect:特效類別的基礎,專門用於特效物件的相關類別(如魔法彈)。

如果要用個簡單的示意圖來表示的話,應該就會像是這樣:

GameObject
 ├── BaseCharacter
 │     ├── Witch
 │     └── Enemy
 └── BaseEffect
       └── MagicBullet

一、GameObject - 遊戲物件基底

// Games/GameObject.ts
export class GameObject extends PIXI.Container {

	protected _sprite: PIXI.Sprite;

	constructor() {
		super();

		// 預先建立一個沒有紋理的 Sprite,錨點置中
		const sprite = this._sprite = new PIXI.Sprite({
			anchor: 0.5
		});
		this.addChild(sprite);

	}

	/**
	 * 設置 Sprite 紋理。
	 * @param texture - 紋理物件
	 */
	setSpriteTexture(texture: PIXI.Texture): void {
		this._sprite.texture = texture;
	}

	/**
	 * 更新循環函數。
	 * @param dt - 每幀間隔時間(ms)
	 */
	update(dt: number): void {
		// 子物件可以複寫這個函數來實作自己的更新邏輯
	}
}

二、BaseCharacterBaseEffect:角色/特效類別基底

有了 GameObject,我們就可以來建立針對不同類型物件的基底。這邊我就只單純展示 BaseCharacter 的程式碼,因為 BaseEffect 目前跟 BaseCharacter 幾乎沒有差別,但未來開始各自新增功能時,差異化應該就會體現出來了。

// Game/Characters/BaseCharacter.ts
import { GameObject } from './../GameObject';
import pixi = CG.Pixi.pixi;

export class BaseCharacter extends GameObject {

	/**
	 * 設置角色的圖幀。
	 * @param frameName - 角色圖幀名稱(參考圖集動畫資源的圖幀列表)
	 */
	setSpriteFrame(frameName: string): void {
		const spritesheet = pixi.assets.getSpritesheet("LittleWitch_TheJourney.圖集動畫.角色");
		const texture = spritesheet.textures[frameName];
		this.setSpriteTexture(texture);
	}
}

三、調整現有的遊戲物件類別

有了基底的類別以後,我們先來調整現有的 WitchMagicBullet
這邊我用 MagicBullet 來演示,因為它多調整了點東西,Witch 的調整也與之相同。

import { BaseEffect } from './BaseEffect';

// ... (IMagicBulletData 不變)

export class MagicBullet extends BaseEffect {

	constructor(private _data: IMagicBulletData) {
		super();

		// 設定魔法彈的紋理(直接使用基底的函數設定紋理,不需要再處理 PIXI.Sprite 的細節)
		this.setSpriteFrame("magic_bullet");
		this._sprite.scale.set(0.5); // 這個素材圖片稍大,預設縮小成 0.5

		// ... (其餘設定位置、動畫不變)
	}

	// ... (update 函數不變)
}

▸ 實作:Enemy 基礎敵人類別

經過上方的優化以後,我們就可以來開始創建新的 Enemy 敵人類別拉~

我們今天先來製作最簡單的敵人,也就是會從畫面右邊持續往左飛行,直到飛出畫面外消失的敵人。

// Game/Characters/Enemys/Enemy.ts
import { BaseCharacter } from './../BaseCharacter';

// 敵人類型
export enum EnemyType {
	Bat = "bat",
	Ghost = "ghost",
	Pumpkin = "pumpkin"
}

export class Enemy extends BaseCharacter {

	// 敵人向左移動的速度(像素/毫秒)
	private _speed = 0.1;

	/**
	 * @param _type - 敵人類型
	 */
	constructor(private _type: EnemyType = EnemyType.Bat) {
		super();

		// 因為素材統一面向右邊,所以要左右翻轉
		this._sprite.scale.x = -1;
		this.setSpriteFrame(_type);

	}

	/**
	 * 更新循環函數。
	 * @param dt - 每幀間隔時間(ms)
	 */
	update(dt: number): void {
		// 1. 向左移動
		this.x -= this._speed * dt;

		// 2. 邊界檢查與銷毀(當敵人飛出左側畫面時)
		const halfWidth = this._sprite.width * this.scale.x * 0.5;

		// 當整個敵人的右邊界(中心點 + 半寬)飛出左側舞台時,銷毀它
		if (this.x + halfWidth < 0) {
			this.destroy({ children: true });
		}
	}
}
  • EnemyType:我們使用 enum (列舉) 來定義敵人的所有類型,這樣我們在呼叫生成函數時,就不會傳入一個不存在的敵人名稱字串,提升了程式碼的安全性。
  • BaseCharacter 的優勢:敵人直接繼承 BaseCharacter,可以輕鬆地呼叫 setSpriteFrame(),並且擁有 _sprite 屬性來處理翻轉等細節。

目前敵人的邏輯非常單純,enemyType 也只會改變敵人的外觀而已,不過這個部分之後再來優化,讓不同種類的敵人,也會有不同的行為模式!

▸ 調整 Game 類別

有了敵人之後,讓我們先來稍微調整一下 Game 類別,使其可以更方便的在遊戲中添加敵人,順便調整 _update 函數的型別宣告。

// ... (其餘 import 不變)
import { EnemyType, Enemy } from './Characters/Enemys/Enemy';
import { GameObject } from './GameObject';

export class Game extends PIXI.Container {

	// ... (其餘不變)

	private _update(dt: number): void {

		// ... (其餘不變)

		// 歷遍特效圖層與角色圖層的所有子物件,以執行 update()
		for (const layer of [this._effectLayer, this._characterLayer]) {
            // 明確告訴 TypeScript 這裡的子物件型別是 GameObject[]
			const children = layer.children as GameObject[];
			for (let i = children.length - 1; i >= 0; --i) {
				const gameObject = children[i];
                // 因為 children 被明確轉型為 GameObject[],所以可以直接呼叫 update()
				gameObject.update(dt);
			}
		}
	}

	/**
	 * 添加敵人。
	 * @param enemyType - 敵人類型
	 */
	addEnemy(enemyType: EnemyType): void {
		const enemy = new Enemy(enemyType);

		// 將敵人位置設置在畫面右邊界的隨機高度
		const halfWidth = enemy.width * 0.5;
		const halfHeight = enemy.height * 0.5;
		enemy.position.set(
			pixi.stageWidth + halfWidth,
			halfHeight + Math.random() * (pixi.stageHeight - enemy.height)
		);

		this._characterLayer.addChild(enemy);
	}
}
  • 型別安全:通過 layer.children as GameObject[],我們明確告訴 TypeScript,這個陣列中的物件都保證有 update(dt) 函數。這樣我們就可以直接呼叫 gameObject.update(dt),徹底告別不安全的 ["update"](dt) 寫法,提高了程式碼的可讀性和安全性!
  • addEnemy(enemyType: EnemyType):這個函數讓 Game 類別具備了「生成敵人」的職責。它計算了一個隨機的 Y 座標和畫面外的 X 座標,確保敵人從畫面右側的隨機位置出現。

到目前為止,理論上我們已經可以在遊戲中添加敵人了,只要讓 game 呼叫 addEnemy 函數並傳入敵人類型,應該就可以看到敵人從舞台畫面右側出現,並且慢慢的往左邊飛行直到消失。

但,這樣還不夠!做為一個 Roguelike 遊戲,我們不可能手動延遲程式碼,去一個個設定每個敵人的出生時機、種類等等。因此接下來就要到今天的重頭戲了,我們將要實作一個波次控制系統,讓我們可以更有效率的定義敵人的生成資料。

▸ 實作:WaveController 波次控制器

這是一個比較龐大的項目,因此先讓我們來稍微規劃一下,把我需要的需求全部列出來。

  1. 時間驅動:所有生成都應該基於遊戲開始後的經過時間 (elapsedTime)。
  2. 多波次切換:能夠在特定時間點 (startTime) 自動切換到下一波次的敵人生成數據。
  3. 自動生成:根據設定的 interval(間隔時間)周期性生成敵人。
  4. 有限/無限:能夠設定生成的 count 數量,讓敵人可以設定為無限生成或只生成幾隻。
import { EnemyType } from './Characters/Enemys/Enemy';
import { Game } from './Game';

// 定義關卡資料(其實就是波次陣列)
export type ILevelData = IWaveData[];

// 定義波次資料
export interface IWaveData {
	startTime: number;
	enemys: IWaveEnemyData[]
}

// 定義敵人生成資料
export interface IWaveEnemyData {
	enemyType: EnemyType; // 敵人類型
	interval: number;	  // 生成間隔時間(毫秒)
	count?: number;		  // 生成數量(沒填表是無限)
	nextTime?: number;	  // 下次生成時間(毫秒,控制器使用,也可用於延遲出生)
}

export class WaveController {

	// 開始時間(毫秒)
	private _startTime: number;
	// 當前波次索引值
	private _currIndex: number = 0;

	constructor(
		private _game: Game,
		private _levelData: ILevelData
	) {

	}

	/**
	 * 開始運行控制器。
	 */
	start(): void {
		this._startTime = Date.now();
		this._currIndex = 0;
	}

	/**
	 * 更新循環函數。
	 * @param dt - 每幀間隔時間(ms)
	 */
	update(dt: number): void {
		// 如果開始時間不是數字,直接結束。
		if (typeof this._startTime !== "number") return;

		// 經過時間
		const elapsedTime = Date.now() - this._startTime;

		// 如果有下一波次,而且波次時間到
		const nextWaveData = this._levelData[this._currIndex + 1];
		if (nextWaveData && elapsedTime >= nextWaveData.startTime) {
			++this._currIndex; // 增加波次索引值
		}

		const enemysData = this._levelData[this._currIndex].enemys;
		for (let i = enemysData.length - 1; i >= 0; --i) {
			const waveEnemyData = enemysData[i];

			const nextTime = waveEnemyData.nextTime ?? elapsedTime;
			// 如果經過時間 < 下次生成時間,則略過
			if (elapsedTime < nextTime) continue;
			// 更新下次生成時間
			waveEnemyData.nextTime = nextTime + waveEnemyData.interval;
			// 使用主遊戲添加敵人
			this._game.addEnemy(waveEnemyData.enemyType);

			// 如果有設定生成數量,則減少數量
			let count = waveEnemyData.count ?? -1;
			if (count > 0) {
				--count;
				// 若減少後數量歸零,則移除該波次資料
				if (count === 0) enemysData.splice(i, 1);
			}
		}
	}
}

▸ 建立測試用關卡資料

為了測試我們製作的波次控制器,接下來我們要來定義一個關卡資料,也讓各位看看使用這個系統該怎麼設計關卡。

import { ILevelData } from './../WaveController';
import { EnemyType } from './../Characters/Enemys/Enemy';

export function getLevel_1(): ILevelData {
	return [{
		startTime: 0,
		enemys: [{
			enemyType: EnemyType.Bat,
			interval: 1000,
		}]
	}, {
		// 十秒鐘後,第二波次
		startTime: 10000,
		enemys: [{
			enemyType: EnemyType.Ghost,
			interval: 1000,
		}]
	}]
}

Game 類別再調整:啟用 WaveController

// ... (其餘 import 不變)
import { WaveController } from './WaveController';
import { getLevel_1 } from './Data/Level_1';

export class Game extends PIXI.Container {

	// ... (其餘屬性不變)

	// 波次控制器
	private _waveController: WaveController;

	constructor() {
		super();

		// ... (其餘初始化不變)

		// 波次控制器
		this._waveController = new WaveController(this, getLevel_1());

	}

	/**
	 * 開始遊戲。
	 */
	start(): void {

		this._waveController.start();

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

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

		this._waveController.update(dt); // 添加波次控制器更新

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

	// ... (addEnemy 函數不變)
}
  • start():由於波次控制器是基於開始時間運作的,因此我們稍微調整一下,添加一個開始函數,讓遊戲被建立後,需要手動呼叫 start() 才能夠開始遊戲。這樣也有幾個好處,例如可以在未來實作「倒數 3, 2, 1」或「開場動畫」等功能時,讓遊戲邏輯精確地在正確的時間點開始運行,而不是在 Gamenew 出來以後就馬上運行。

由於我們調整了 Game 類別的啟動方式,別忘了在 app.ts 專案進入點呼叫這個函數!

// ... (資源載入、初始化不變)

	// 創建遊戲實例
	const game = new Game();
	pixi.root.addChild(game);

	// 新增:手動呼叫 game.start() 啟動遊戲流程
	game.start(); 
}

小女巫 敵人生成 預覽

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

▸ 總結

今天,我們完成了極為關鍵的三大升級:

  1. 程式碼架構升級:引入 GameObject 繼承體系,消除了 gameObject["update"](dt) 的型別安全問題,使專案結構更有序、更安全。
  2. 核心功能實作:創建了 Enemy 基礎類別,實現了基礎的向左移動與邊界銷毀邏輯。
  3. 波次控制系統:實作了 WaveController,將「遊戲流程」與「遊戲邏輯」分離,使得未來的關卡設計只需要修改 Level_1.ts 的資料結構即可,大幅提升了設計效率。如此一來,以後就能很容易做出每 10 秒出一批怪,count 的設計也可以在波次中突然安插特殊種類、行為的敵人,打的玩家們措手不急!

現在敵人會規律且有波次地出現在畫面上,但小女巫的子彈還無法擊中它們,而敵人撞到小女巫時也無事發生。

明天我們將進入真正的戰鬥環節,實作子彈與敵人的碰撞偵測,並為敵人加上血量(HP)屬性,讓我們的魔法彈終於可以造成傷害了!


上一篇
Day 19:專案架構升級!實作 Game 類別,告別混亂的 start()
下一篇
Day 21:基礎碰撞 - 子彈 vs 敵人與生命值(HP)
系列文
用 PixiJS 寫遊戲!告別繁瑣設定,在 Code.Gamelet 打造你的第一個遊戲23
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言