昨天我們完成了專案的架構重構,實作了中央控制器 Game
類別。有了這個穩固的基礎,今天的核心目標是實作一個敵人生成系統,讓敵人能夠有規律、有波次地從畫面右側出現,並朝著小女巫移動。
在開始製作敵人以前,我們先來解決一個小問題。小女巫、魔法彈,再加上今天的敵人,已經有三個遊戲物件了,這些遊戲物件都有一些共同的屬性、函數,例如 _sprite
、update(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 {
// 子物件可以複寫這個函數來實作自己的更新邏輯
}
}
二、BaseCharacter
、BaseEffect
:角色/特效類別基底
有了 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);
}
}
三、調整現有的遊戲物件類別
有了基底的類別以後,我們先來調整現有的 Witch
、MagicBullet
。
這邊我用 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
波次控制器這是一個比較龐大的項目,因此先讓我們來稍微規劃一下,把我需要的需求全部列出來。
elapsedTime
)。startTime
) 自動切換到下一波次的敵人生成數據。interval
(間隔時間)周期性生成敵人。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」或「開場動畫」等功能時,讓遊戲邏輯精確地在正確的時間點開始運行,而不是在 Game
被 new
出來以後就馬上運行。由於我們調整了 Game
類別的啟動方式,別忘了在 app.ts
專案進入點呼叫這個函數!
// ... (資源載入、初始化不變)
// 創建遊戲實例
const game = new Game();
pixi.root.addChild(game);
// 新增:手動呼叫 game.start() 啟動遊戲流程
game.start();
}
今天,我們完成了極為關鍵的三大升級:
GameObject
繼承體系,消除了 gameObject["update"](dt)
的型別安全問題,使專案結構更有序、更安全。Enemy
基礎類別,實現了基礎的向左移動與邊界銷毀邏輯。WaveController
,將「遊戲流程」與「遊戲邏輯」分離,使得未來的關卡設計只需要修改 Level_1.ts
的資料結構即可,大幅提升了設計效率。如此一來,以後就能很容易做出每 10 秒出一批怪,count
的設計也可以在波次中突然安插特殊種類、行為的敵人,打的玩家們措手不急!現在敵人會規律且有波次地出現在畫面上,但小女巫的子彈還無法擊中它們,而敵人撞到小女巫時也無事發生。
明天我們將進入真正的戰鬥環節,實作子彈與敵人的碰撞偵測,並為敵人加上血量(HP)屬性,讓我們的魔法彈終於可以造成傷害了!