iT邦幫忙

2025 iThome 鐵人賽

DAY 21
1

昨天我們為專案打下了穩固的架構,並成功讓敵人有規律地從畫面上出現。然而,目前小女巫的魔法彈就像穿過空氣一樣,對敵人毫無作用。

今天的核心目標就是讓我們的遊戲進入真正的戰鬥環節:實作子彈與敵人的碰撞偵測,並引入生命值(HP)傷害機制

同時,為了應對未來多種敵人(不同 HP、不同攻擊力),我們將進行一次重要的結構升級,讓敵人配置可以被數據驅動

CollisionUtil:碰撞檢查小工具

遊戲中的碰撞檢測,通常不會直接使用物件的邊界,而是使用碰撞箱(HitBox)。為了方便我們檢查兩個物件之間的碰撞判定,我們要先來建立一個 CollisionUtil 靜態類別,專門負責處理碰撞邏輯。

目前我預計讓角色使用矩形,而魔法彈則使用圓形的碰撞箱,因此我們要來設計 矩形 vs 圓形 的碰撞檢測。

// Utils/CollisionUtil.ts
export type IHitBox = PIXI.Rectangle | PIXI.Circle;

export class CollisionUtil {

	/**
	 * 檢查矩形與圓形是否產生碰撞。
	 * @param rect   - 矩形碰撞體
	 * @param circle - 圓形碰撞體
	 */
	static checkRectCircleCollision(rect: PIXI.Rectangle, circle: PIXI.Circle): boolean {
		// 找到圓心距離矩形最近的點 (x, y)
		const closestX = Math.max(rect.x, Math.min(circle.x, rect.x + rect.width));
		const closestY = Math.max(rect.y, Math.min(circle.y, rect.y + rect.height));

		// 計算最近點與圓心的距離
		const distanceX = circle.x - closestX;
		const distanceY = circle.y - closestY;

		// 若最近點與圓心的距離平方 < 圓形半徑平方,則發生碰撞
		return (distanceX * distanceX + distanceY * distanceY) < (circle.radius * circle.radius);
	}

	/**
	 * 檢查兩個碰撞箱是否產生碰撞。
	 * @param a - 碰撞箱 A
	 * @param b - 碰撞箱 B
	 */
	static check(a: IHitBox, b: IHitBox): boolean {

		// 處理 矩形 vs 圓形 碰撞
		if (a instanceof PIXI.Rectangle && b instanceof PIXI.Circle) {
			return CollisionUtil.checkRectCircleCollision(a, b);
		}
		if (a instanceof PIXI.Circle && b instanceof PIXI.Rectangle) {
			return CollisionUtil.checkRectCircleCollision(b, a);
		}

		// 目前只需要處理這兩種,未來可以擴充矩形 vs 矩形等...
		return false;
	}
}
  • PIXI.RectanglePIXI.Circle:它們都是 PixiJS 提供的基本幾何圖形類別,繼承自 PIXI.Shape。雖然它們主要用於繪圖或計算,但因為它們內建了 xywidthheight(或 radius)等屬性,非常適合用來作為碰撞箱的數據結構。這樣可以避免我們需要自己創建額外的類別來儲存這些座標資訊。

GameObject:碰撞箱與開發預覽

有了方便的小工具之後,現在,我們要來將碰撞體屬性與碰撞測試函數,整合到所有遊戲物件的基底:GameObject

接下來我們要新增幾個功能:

  1. hitBox相對座標的碰撞體(例如圓心在 (0, 0))。
  2. realHitBox絕對座標的碰撞體(會在每次讀取時加上物件的當前位置)。
  3. _hitBoxPreview開發專用的圖形,用於在畫面上實時顯示碰撞體,方便調試。
  4. hitTest()碰撞測試的函數,讓我們可以用更快速統一的方式檢查與其他物件的碰撞。
import { CollisionUtil, IHitBox } from './../Utils/CollisionUtil';

export class GameObject extends PIXI.Container {

	// ... (其餘省略)

	// 碰撞箱(可使用矩形或圓形)
	private _hitBox: IHitBox;
	// 真實碰撞箱(相對於物件座標位置,用於碰撞判定)
	private _realHitBox: IHitBox;
	// 碰撞箱預覽圖形
	private _hitBoxPreview: PIXI.Graphics;

	constructor() {
		super();

		// ... (其餘省略)

		// 如果是開發模式,則建立碰撞箱預覽圖形
		if (CG.Base2.system.devMode) {
			const hitBoxPreview = this._hitBoxPreview = new PIXI.Graphics();
			this.addChild(hitBoxPreview);
		}

	}

	// 碰撞箱(使用 get 可讓外部訪問內部屬性,但無法修改它)
	get hitBox(): IHitBox { return this._hitBox }
	// 真實碰撞箱(每次讀取時更新位置)
	get realHitBox(): IHitBox {
		const realHitBox = this._realHitBox;
		realHitBox.x = this.x + this._hitBox.x;
		realHitBox.y = this.y + this._hitBox.y;
		return realHitBox;
	}
	
	/**
	 * 設置碰撞箱。
	 * @param hitBox - 碰撞箱
	 */
	setHitBox(hitBox: IHitBox) {
		this._hitBox = hitBox;
		// 同步更新真實碰撞箱
		this._realHitBox = hitBox.clone();
		// 如果 hitBoxPreview 物件存在,則重新繪製
		const hitBoxPreview: PIXI.Graphics = this._hitBoxPreview;
		if (hitBoxPreview) {
			// 先清除舊有的圖形
			hitBoxPreview.clear();
			if (hitBox instanceof PIXI.Rectangle) {
				hitBoxPreview.rect(hitBox.x, hitBox.y, hitBox.width, hitBox.height);
			} else if (hitBox instanceof PIXI.Circle) {
				hitBoxPreview.circle(hitBox.x, hitBox.y, hitBox.radius)
			}
			// 使用半透明的紅色
			hitBoxPreview.fill({ color: 0xFF0000, alpha: 0.5 } as PIXI.FillStyle);
		}
	}

	/**
	 * 碰撞測試,檢查兩個 GameObject 是否產生碰撞。
	 * @param other - 另一個 GameObject
	 */
	hitTest(other: GameObject): boolean {
		return CollisionUtil.check(this.realHitBox, other.realHitBox);
	}

	// ... (其餘省略)
}
  • CG.Base2.system.devMode:這是 CG 提供的,用來判斷當前的執行環境是否為開發模式,正式的遊戲成品 devMode 會是 false,利用這個屬性我們可以製作一些只用於開發中的測試功能。
  • getsetgetset(Getter、Setter)屬性,允許我們在外部讀取、賦值時,實際上是在執行某一段函數。我們可以單純的只設定 get 回傳私有屬性,來做到唯獨保護的效果。或是在讀取、設定的同時,執行某些額外的處理(例如在 GameObjectset hitBox 中,我們需要同步更新預覽圖和 _realHitBox)。它們是實現數據封裝、增加程式碼安全性的重要手段。

BaseCharacter

為了讓遊戲可以運行戰鬥邏輯,我們為所有角色的基底 BaseCharacter 加上了 HP(血量)MaxHp(最大血量)AttackDamage(攻擊力) 屬性,並實作了核心的 setHp()takeDamage() 函數。

值得注意的是,我們將角色死亡的邏輯(當 hp <= 0 時呼叫 destroy())直接封裝在 setHp() 中。這樣可以確保所有繼承它的角色都能自動處理死亡,無需重複撰寫。

// ... (其餘省略)

export class BaseCharacter extends GameObject {

	// 角色血量
	private _hp: number = 1;
	// 角色最大血量
	private _maxHp: number = 1;
	// 角色攻擊力
	private _attackDamage: number = 1;

	// 角色血量(使用 get 可讓外部訪問內部屬性,但無法修改它)
	get hp(): number { return this._hp }
	// 角色最大血量(使用 get 可讓外部訪問內部屬性,但無法修改它)
	get maxHp(): number { return this._maxHp }
	// 角色是否存活
	get isAlive(): boolean { return this._hp > 0 }
	// 角色攻擊力
	get attackDamage(): number { return this._attackDamage }

	/**
	 * 設定生命值。
	 * @param hp    - 當前生命值
	 * @param maxHp - 最大生命值
	 */
	setHp(hp: number, maxHp?: number): void {
		if (typeof maxHp === "number") {
			maxHp = this._maxHp = Math.max(1, maxHp);
		}

		this._hp = Math.max(0, Math.min(hp, maxHp ?? this._maxHp));
		// 利用 isAlive 來檢查是否死亡
		if (!this.isAlive) {
			this.destroy({ children: true });
		}
	}

	/**
	 * 設定攻擊力。
	 * @param attackDamage - 攻擊力
	 */
	setAttackDamage(attackDamage: number): void {
		this._attackDamage = attackDamage;
	}

	// ... (其餘省略)

	/**
	 * 受到傷害。
	 * @param damage - 傷害數值
	 */
	takeDamage(damage: number): void {
		this.setHp(this._hp - damage);
	}
}

EnemyType 架構升級:數據驅動

為了讓敵人配置更加靈活,我們將原本單純的 enum 升級為一個數據驅動的類別結構。現在,我們可以將敵人的 HP、攻擊力、碰撞箱 等配置直接定義在 EnemyType 中。

// Games/Characters/Enemys/EnemyType.ts
import { IHitBox } from './../../../Utils/CollisionUtil';

export interface IEnemyConfig {
	code?: string;
	hitBox: IHitBox;
	maxHp: number;
	attackDamage: number;
}

// 敵人類型
export class EnemyType {

	static readonly ALL: { [code: string]: EnemyType } = {};

	static Bat = new EnemyType("bat", {
		hitBox: new PIXI.Rectangle(-10, -13, 20, 26),
		maxHp: 1,
		attackDamage: 1
	});
	static Ghost = new EnemyType("ghost", {
		hitBox: new PIXI.Rectangle(-19, -35, 38, 70),
		maxHp: 2,
		attackDamage: 1
	});
	static Pumpkin = new EnemyType("pumpkin", {
		hitBox: new PIXI.Rectangle(-24, -21, 48, 42),
		maxHp: 3,
		attackDamage: 1
	});

	static getByCode(code: string): EnemyType {
		return EnemyType.ALL[code];
	}

	constructor(
		readonly code: string,
		readonly config: IEnemyConfig) {
		config.code = code;
		EnemyType.ALL[code] = this;
	}

}
  • getByCode():雖然我們完全可以在其他地方使用 EnemyType.ALL[code] 來取得敵人類型,但透過定義一個靜態方法 getByCode(code: string): EnemyType,我們可以隱藏內部實現細節(例如 EnemyType.ALL 其實是一個物件),並在未來對獲取過程進行額外處理(例如錯誤檢查、找不到時返回預設值),從而增加了程式碼的彈性與強健性

Enemy:配置導入與碰撞箱設定

有了新的 EnemyType 類別後,Enemy 類別現在可以從配置中自動載入血量、攻擊力和碰撞箱,而不需要在 Enemy 內部手動設定這些參數了。

// ... (其餘省略)
import { EnemyType } from './EnemyType';

// 移除這裡原本的 enum EnemyType 定義

export class Enemy extends BaseCharacter {

	// ... (其餘省略)

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

		// ... (其餘省略)

		// 設定 config 內的屬性
		const { hitBox, maxHp, attackDamage } = _type.config;
		this.setHitBox(hitBox);
		this.setHp(maxHp, maxHp);
		this.setAttackDamage(attackDamage);

	}

	// ... (其餘省略)
}

Game:提供角色列表

為了讓魔法彈(或其他攻擊型物件)能夠知道場景中有哪些可以被攻擊的目標,我們在 Game 類別中新增一個簡單的函數 getAllCharacters(),它負責獲取角色圖層中的所有物件。

// ... (其餘省略)
import { BaseCharacter } from './Characters/BaseCharacter';

export class Game extends PIXI.Container {

	// ... (其餘省略)
    
    constructor() {
		super();

		// ... (其餘省略)

		// 接收小女巫攻擊事件,生成魔法彈並加入特效圖層
		witch.on(Witch.EVENT.ATTACK, (data: IMagicBulletData) => {
			const bullet = new MagicBullet(this, data); // 這裡要記得傳入 this,也就是 Game 實例
			effectLayer.addChild(bullet);
		}, this);

		// ... (其餘省略)

	}

	/**
	 * 取得所有角色。
	 */
	getAllCharacters(): BaseCharacter[] {
		return this._characterLayer.children as BaseCharacter[];
	}
	
	// ... (其餘省略)
}

MagicBullet:實戰碰撞與傷害

現在,魔法彈(MagicBullet)具備了「知道自己傷害值」的能力,也擁有了一個指向 Game 類別的引用。

接下來我們就可以讓它在 update() 中:

  1. Game 請求當前場景中的所有角色。
  2. 逐一檢查是否與敵人發生碰撞 (hitTest(character))。
  3. 如果發生碰撞,則對敵人呼叫 takeDamage(),然後讓子彈銷毀自己 (destroy())。
// ... (其餘省略)

// 定義生成魔法彈時需要的資料
export interface IMagicBulletData {
	// ... (其餘省略)
	damage: number; // 添加新的傷害屬性
}

export class MagicBullet extends BaseEffect {

	private _damage: number;

	constructor(
		private _game: Game, // 添加新參數,傳入 Game 物件
		private _data: IMagicBulletData) {
		super();

		// ... (其餘省略)

		// 設定魔法彈的碰撞體(由於魔法彈的素材是一個正圓形,也沒有多餘的留白,因此半徑我就直接用寬 * 0.5)
		this.setHitBox(new PIXI.Circle(0, 0, this.width * 0.5));
		// 設置魔法彈的傷害
		this._damage = _data.damage;

		// ... (其餘省略)
	}

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

		// ... (其餘省略)

		// 利用 _game 來取得所有角色。
		const characters = this._game.getAllCharacters();
		for (let i = characters.length - 1; i >= 0; --i) {
			const character = characters[i];
			// 如果該角色的類別不是 Enemy,或是敵人已死亡則略過
			if (!(character instanceof Enemy) || !character.isAlive) continue;
			// 使用 GameObject 的 hitTest 函數來進行碰撞測試
			if (this.hitTest(character)) {
				character.takeDamage(this._damage);
				this.destroy({ children: true });
				return;
			}
		}

		// ... (其餘省略)
	}
}

Witch:發射帶有傷害的魔法彈

最後,我們需要讓 Witch 在發射攻擊事件時,帶上自己的攻擊力數值。

// ... (其餘省略)

export class Witch extends BaseCharacter {

	// ... (其餘省略)

	/**
	 * 發射魔法彈。
	 */
	private _launchMagicBullet(): void {
		this.emit(Witch.EVENT.ATTACK, {
			pos: { x: this.x + 50, y: this.y + 31 },
			dir: this._bulletSpeed,
			scale: 1,
			damage: this.attackDamage // 傳入女巫的攻擊力
		} as IMagicBulletData);
	}

	// ... (其餘省略)
}

▸ 調整測試用關卡資料

為了測試三種類型敵人的碰撞箱是否正確,我們來稍微調整 getLevel_1() 內回傳的關卡資料,讓三種類型的敵人都會不斷的生成。

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

export function getLevel_1(): ILevelData {
	return [{
		startTime: 0,
		enemys: [{
			enemyType: EnemyType.Bat,
			interval: 1500,
		}, {
			enemyType: EnemyType.Ghost,
			interval: 1500,
			nextTime: 500
		}, {
			enemyType: EnemyType.Pumpkin,
			interval: 1500,
			nextTime: 1000
		}]
	}]
}

▸ 其他優化

  1. Enemy 的預設飛行速度改為 0.2。由於昨天設為 0.1 的時候,在背景與敵人的對比之下,就像是他們一直在往後飛一樣,因為卷軸背景的前景移動速度是 0.15,因此這邊設定成 > 0.15 才不會看起來像是在往後飛。
  2. Witch 的攻擊間隔時間改為 500。因為原本的 1000 看起來有點乏味,再加上敵人出現的頻率可能不會這麼慢。

小女巫 子彈與敵人的碰撞檢測 預覽

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

▸ 總結

今天,我們完成了遊戲的核心戰鬥機制:

  1. 碰撞系統:實作了 CollisionUtil 類別和 GameObject 的碰撞箱屬性,成功支援了 矩形 vs 圓形 的碰撞偵測。
  2. 屬性擴充BaseCharacter 擁有了 HPAttackDamage 屬性,並內建了死亡處理邏輯。
  3. 數據驅動:將敵人的配置從程式碼邏輯中剝離,轉移到 EnemyType 靜態類別進行管理,實現了高靈活性。
  4. 戰鬥循環MagicBullet 現在能在 update() 循環中找到敵人,執行 hitTest,並透過 takeDamage 造成傷害。

現在,小女巫的魔法彈終於能夠消滅敵人了!

明天我們將進入防禦環節:實作敵人與女巫的碰撞、女巫的受傷邏輯、無敵幀機制,並在畫面上加上血條 UI,讓戰鬥體驗更完整!


上一篇
Day 20:敵人登場!實作敵人生成系統與基礎敵人類別
下一篇
Day 22:小女巫受傷、無敵幀與血條 UI
系列文
用 PixiJS 寫遊戲!告別繁瑣設定,在 Code.Gamelet 打造你的第一個遊戲23
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言