昨天我們為專案打下了穩固的架構,並成功讓敵人有規律地從畫面上出現。然而,目前小女巫的魔法彈就像穿過空氣一樣,對敵人毫無作用。
今天的核心目標就是讓我們的遊戲進入真正的戰鬥環節:實作子彈與敵人的碰撞偵測,並引入生命值(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.Rectangle
、PIXI.Circle
:它們都是 PixiJS 提供的基本幾何圖形類別,繼承自 PIXI.Shape
。雖然它們主要用於繪圖或計算,但因為它們內建了 x
、y
、width
、height
(或 radius
)等屬性,非常適合用來作為碰撞箱的數據結構。這樣可以避免我們需要自己創建額外的類別來儲存這些座標資訊。GameObject
:碰撞箱與開發預覽有了方便的小工具之後,現在,我們要來將碰撞體屬性與碰撞測試函數,整合到所有遊戲物件的基底:GameObject
。
接下來我們要新增幾個功能:
hitBox
:相對座標的碰撞體(例如圓心在 (0, 0))。realHitBox
:絕對座標的碰撞體(會在每次讀取時加上物件的當前位置)。_hitBoxPreview
:開發專用的圖形,用於在畫面上實時顯示碰撞體,方便調試。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
,利用這個屬性我們可以製作一些只用於開發中的測試功能。get
、set
:get
、set
(Getter、Setter)屬性,允許我們在外部讀取、賦值時,實際上是在執行某一段函數。我們可以單純的只設定 get
回傳私有屬性,來做到唯獨保護的效果。或是在讀取、設定的同時,執行某些額外的處理(例如在 GameObject
的 set 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()
中:
Game
請求當前場景中的所有角色。hitTest(character)
)。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
}]
}]
}
Enemy
的預設飛行速度改為 0.2
。由於昨天設為 0.1
的時候,在背景與敵人的對比之下,就像是他們一直在往後飛一樣,因為卷軸背景的前景移動速度是 0.15
,因此這邊設定成 > 0.15 才不會看起來像是在往後飛。Witch
的攻擊間隔時間改為 500
。因為原本的 1000
看起來有點乏味,再加上敵人出現的頻率可能不會這麼慢。今天,我們完成了遊戲的核心戰鬥機制:
CollisionUtil
類別和 GameObject
的碰撞箱屬性,成功支援了 矩形 vs 圓形 的碰撞偵測。BaseCharacter
擁有了 HP 和 AttackDamage 屬性,並內建了死亡處理邏輯。EnemyType
靜態類別進行管理,實現了高靈活性。MagicBullet
現在能在 update()
循環中找到敵人,執行 hitTest
,並透過 takeDamage
造成傷害。現在,小女巫的魔法彈終於能夠消滅敵人了!
明天我們將進入防禦環節:實作敵人與女巫的碰撞、女巫的受傷邏輯、無敵幀機制,並在畫面上加上血條 UI,讓戰鬥體驗更完整!