昨天我們完成了小女巫對敵人的傷害處理,但作為一個 Roguelike 遊戲,不能讓玩家輕鬆地站在原地不動。今天的核心目標是完成防禦機制,實作敵人對女巫的傷害,並加入遊戲體驗至關重要的 無敵幀(Invincibility Frame, IFrame),同時在畫面上視覺化女巫的生命值。
BaseCharacter
:標準化受傷與死亡為了讓所有角色(女巫、敵人、未來的 Boss 等)都能有一致且可控的受傷與死亡流程,我們要先來優化角色基底類別:
death()
):將角色死亡獨立為一個 async
函數。這使得死亡處理能夠包含非同步的視覺特效,然後才銷毀物件。_isAlive
):引入 _isAlive
屬性,用來確保 death()
函數不會被重複觸發,並提供了給外部檢查角色狀態的唯讀屬性。EVENT.HURT
):在 takeDamage()
中加入了 BaseCharacter.EVENT.HURT
事件發送。這是為了今天的血條 UI 做準備,讓血條 UI 能夠直接監聽角色受傷,從而實現程式碼解耦。// ... (其餘省略)
export class BaseCharacter extends GameObject {
// 定義靜態事件名稱,方便使用
static EVENT = {
HURT: "hurt" // 添加角色受傷的事件
}
// ... (其餘省略)
// 角色使否存活(改成建立新的標籤屬性)
private _isAlive: boolean = true;
// 角色是否存活
get isAlive(): boolean { return this._isAlive }
/**
* 設定生命值。
* @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));
// 檢查血量是否歸零,以及存活狀態
if (this._hp <= 0 && this._isAlive) this.death();
}
// ... (其餘省略)
/**
* 受到傷害。
* @param damage - 傷害數值
*/
takeDamage(damage: number): void {
this.setHp(this._hp - damage);
// 添加發送角色受傷的事件
this.emit(BaseCharacter.EVENT.HURT, { damage: damage });
}
/**
* 讓角色死亡。
*/
async death(): Promise<void> {
if (!this._isAlive) return;
this._hp = 0;
this._isAlive = false;
await CG.Base2.waitTween(new TWEEN.Tween(this).to({ alpha: 0 }, 200)
.easing(TWEEN.Easing.Cubic.Out).start());
this.destroy({ children: true });
}
}
CG.Base2.waitTween
:除了 CG.Base2.wait
這種可以在非同步函數等待一段時間的函數,Base2
也另外提供了專門用於等待 Tween
完成的函數。Witch
:核心防禦機制 - 無敵幀小女巫作為玩家控制的角色,其受傷邏輯需要更複雜的判斷,這就是無敵幀登場的時候。當角色受到傷害後的一小段時間內(例如 1000 毫秒),不應該再受到任何傷害。
// ... (其餘省略)
export class Witch extends BaseCharacter {
// 定義靜態事件名稱,方便使用
static EVENT = {
...BaseCharacter.EVENT, // 先展開基底的 EVENT 屬性,再添加新屬性
ATTACK: "witch_attack"
}
// ... (其餘省略)
// 無敵時間(毫秒)
private _invincibleDuration: number = 1000;
// 下次可受傷時間(毫秒)
private _nextDamageableTime: number = 0;
// 添加傳入 Game 實例
constructor(private _game: Game) {
super();
// ... (其餘省略)
// 設定小女巫的碰撞箱
this.setHitBox(new PIXI.Rectangle(-37, -32, 74, 64));
// 設定小女巫的預設血量
this.setHp(5, 5);
}
// 是否處於無敵狀態
get isInvincible(): boolean { return this._nextDamageableTime > Date.now() }
// ... (其餘省略)
/**
* 受到傷害。
* @param damage - 傷害數值
* @override 複寫 BaseCharacter 的受傷函數。
*/
takeDamage(damage: number): void {
// 檢查是否處於無敵狀態,如果是則不受到傷害
if (this.isInvincible) return;
// 呼叫基底函數執行扣血
super.takeDamage(damage);
// 如果沒有死亡,則更新下次可受傷時間
if (this.isAlive) {
this._nextDamageableTime = Date.now() + this._invincibleDuration;
}
}
/**
* 更新循環函數。
* @param dt - 每幀間隔時間(ms)
*/
update(dt: number): void {
// 如果已經死亡則直接結束
if (!this.isAlive) return;
// ... (其餘省略)
// 如果是無敵狀態,則讓小女巫閃爍
if (this.isInvincible) {
// 閃爍效果:每 50 毫秒切換可見性
this._sprite.visible = (Date.now() % 100) > 50;
} else {
// 如果不是無敵狀態
// 重置可見性
if (!this._sprite.visible) this._sprite.visible = true;
// 嘗試與敵人的碰撞檢測
const characters = this._game.getAllCharacters();
for (const enemy of characters) {
if (!(enemy instanceof Enemy) || !enemy.isAlive) continue;
if (this.hitTest(enemy)) {
this.takeDamage(enemy.attackDamage);
break;
}
}
}
}
}
takeDamage
:由於無敵幀的存在,小女巫的 takeDamage
必須擁有不同的運作邏輯。以上方為例子,如果是無敵期間,直接略過 takeDamage
的執行,否則使用 super
呼叫父類的 takeDamage
邏輯,並更新無敵幀的時間。this._game.getAllCharacters()
),並使用 hitTest(enemy)
檢查是否發生碰撞。一旦碰撞,就呼叫 this.takeDamage(enemy.attackDamage)
並立即 break
跳出迴圈,避免一個敵人造成多次傷害。update()
中,我們利用 this.isInvincible
的判斷,讓小女巫的 Sprite
每隔 50 毫秒切換一次可見性,製造出經典的閃爍效果,為玩家提供了清晰的視覺回饋。CollisionUtil
:支援矩形碰撞由於女巫(矩形)和敵人(矩形)之間需要進行碰撞,我們將 CollisionUtil
進行擴充,加入了 checkRectRectCollision
邏輯,這是一種稱為 **AABB(Axis-Aligned Bounding Box)**的簡單矩形碰撞算法,它只基於座標軸進行檢查。
另外我也順便添加了圓形和圓形 checkCircleCircleCollision
的碰撞邏輯。
// ... (IHitBox 不變)
export class CollisionUtil {
// ... (矩形與圓形 不變)
/**
* 檢查兩個矩形是否產生碰撞。
* @param rectA - 矩形碰撞體 A
* @param rectB - 矩形碰撞體 B
*/
static checkRectRectCollision(rectA: PIXI.Rectangle, rectB: PIXI.Rectangle): boolean {
// 檢查 X 軸是否有重疊
const overlapX = rectA.x < rectB.x + rectB.width && rectA.x + rectA.width > rectB.x;
// 檢查 Y 軸是否有重疊
const overlapY = rectA.y < rectB.y + rectB.height && rectA.y + rectA.height > rectB.y;
// 兩個軸向都重疊則發生碰撞
return overlapX && overlapY;
}
/**
* 檢查兩個圓形是否產生碰撞。
* @param circleA - 圓形碰撞體 A
* @param circleB - 圓形碰撞體 B
*/
static checkCircleCircleCollision(circleA: PIXI.Circle, circleB: PIXI.Circle): boolean {
// 計算兩圓心之間的距離平方
const distanceX = circleA.x - circleB.x;
const distanceY = circleA.y - circleB.y;
const distanceSquared = distanceX * distanceX + distanceY * distanceY;
// 計算兩圓半徑之和
const radiusSum = circleA.radius + circleB.radius;
// 若距離平方 <= 半徑和的平方,則發生碰撞
return distanceSquared <= (radiusSum * radiusSum);
}
/**
* 檢查兩個碰撞箱是否產生碰撞。
* @param a - 碰撞箱 A
* @param b - 碰撞箱 B
*/
static check(a: IHitBox, b: IHitBox): boolean {
// 處理 矩形 vs 矩形 碰撞
if (a instanceof PIXI.Rectangle && b instanceof PIXI.Rectangle) {
return CollisionUtil.checkRectRectCollision(a, b);
}
// 處理 圓形 vs 圓形 碰撞
if (a instanceof PIXI.Circle && b instanceof PIXI.Circle) {
return CollisionUtil.checkCircleCircleCollision(a, b);
}
// ... (矩形與圓形 不變)
// 不支援的組合
return false;
}
}
ProgressBar
:可重複使用的 UI 框架為了讓血條 UI 能夠美觀且可控,我們創建了一個通用的 ProgressBar
基礎類別。
TilingSprite
應用:進度條的填充部分採用 PIXI.TilingSprite
,這可以確保無論進度條多長,其內部紋理都能保持平鋪且不被拉伸。PIXI.Graphics
創建一個矩形遮罩來控制進度條的可視範圍。在 set progress()
時,我們透過計算移動 _bar
實例的位置,配合遮罩,實現了進度條的視覺效果。// Games/Uis/ProgressBar.ts
import pixi = CG.Pixi.pixi;
export interface IProgressBarOptions {
width: number;
height: number;
color?: number;
bgFill?: PIXI.FillStyle;
progress?: number;
}
export class ProgressBar extends PIXI.Container {
protected _barBg: PIXI.Graphics;
protected _bar: PIXI.TilingSprite;
// 進度 (0 ~ 1)
private _progress: number = 0;
constructor(protected _options: IProgressBarOptions) {
super();
// 定義預設值,預設有黑色半透明背景
_options = {
bgFill: {
color: 0x000000,
alpha: 0.3
},
..._options
}
const { width, height, color, bgFill, progress } = _options;
// 如果有背景的參數,則優先創建
if (bgFill) {
const barBg = this._barBg = new PIXI.Graphics()
.rect(-width * 0.5, -height * 0.5, width, height)
.fill(bgFill);
this.addChild(barBg);
}
// 建立進度條
const texture = pixi.assets.getSpritesheet("LittleWitch_TheJourney.圖集動畫.uis").textures["bar_fill"];
const bar = this._bar = new PIXI.TilingSprite({
width, height,
texture: texture,
anchor: 0.5
});
bar.tint = color ?? 0xFFFFFF;
this.addChild(bar);
// 建立遮罩
const mask = new PIXI.Graphics()
.rect(-width * 0.5, -height * 0.5, width, height)
.fill();
bar.setMask({ mask: mask });
this.addChild(mask);
// 首次更新進度
this.progress = progress ?? 0;
}
// 用於外部排版時方便使用
get barWidth(): number { return this._options.width }
get barHeight(): number { return this._options.height }
// 進度 (0 ~ 1)
get progress(): number { return this._progress }
set progress(value: number) {
value = this._progress = Math.max(0, Math.min(value, 1));
this._bar.x = -this._options.width * (1 - value);
}
}
bar.tint
:為了這個進度條類別的通用性,我在準備素材時預設的顏色是白色,也就是 tint
的預設顏色,這樣就可以在程式碼中指定 tint
的顏色來顯示各種不同顏色的進度條。以前我自己在實作進度條時,其實都是直接使用 PIXI.Graphics
來繪製背景、Bar 條、邊框之類的,這次因為有小女巫的美術風格的關係,單純使用這種純色圖形去繪製進度條看起來應該會很奇怪。剛好我們在 Day 16 實作視差捲軸時介紹了 PIXI.TilingSprite
,很適合這種 Sprite
可以無限平舖的圖片素材,剛好可以用來銜接這次的進度條實作。
HealthBar
:事件驅動的平滑血條HealthBar
繼承自 ProgressBar
,並專門負責處理角色的生命值。
// Games/Uis/HealthBar.ts
import { ProgressBar } from './ProgressBar';
import pixi = CG.Pixi.pixi;
import { BaseCharacter } from './../Characters/BaseCharacter';
export class HealthBar extends ProgressBar {
// 血條邊框
private _barFrame: PIXI.Sprite;
// 與血條連動的角色
private _character: BaseCharacter;
constructor() {
super({
width: 147,
height: 19,
color: 0xF1D569,
progress: 1
});
// 血條邊框
const barFrame = this._barFrame = new PIXI.Sprite({
texture: pixi.assets.getSpritesheet("LittleWitch_TheJourney.圖集動畫.uis").textures["health_bar_frame"],
anchor: 0.5
});
this.addChild(barFrame);
}
/**
* 當接收到角色受傷時。
*/
private _onCharacterHurt(): void {
const { hp, maxHp } = this._character;
new TWEEN.Tween(this).to({ progress: hp / maxHp }, 100).easing(TWEEN.Easing.Cubic.Out).start();
}
/**
* 設定與血條連動的角色。
* @param character - 角色
*/
setCharacter(character: BaseCharacter): void {
const oldCharacter = this._character;
// 如果有舊的角色先清除事件監聽
if (oldCharacter) {
oldCharacter.off(BaseCharacter.EVENT.HURT, this._onCharacterHurt, this);
}
// 設置事件監聽並立即更新當前血條進度
this._character = character;
character.on(BaseCharacter.EVENT.HURT, this._onCharacterHurt, this);
this.progress = character.hp / character.maxHp;
}
}
setCharacter()
函數中,HealthBar
訂閱(on
)了目標角色的 BaseCharacter.EVENT.HURT
事件,並在有舊角色的情況下,取消訂閱(off
)舊角色的事件。_onCharacterHurt()
被呼叫時,它使用 TWEEN
(補間動畫),讓 progress
屬性在 100 毫秒內平滑地從舊值過渡到新值(hp / maxHp
),避免血條突兀地跳動,稍微提升了遊戲的精緻感。GameUILayer
:UI 介面的排版由於除了玩家的血條 UI 以外,之後可能還會有各式各樣的 UI 需要管理,因此我打算新增一個 GameUILayer
(遊戲 UI 圖層),專門負責管理和定位所有 UI 元件的圖層。
我們在這裡將愛心圖示和血條進行定位,確保它們固定在畫面的左上角。
import { Game } from './../Game';
import { HealthBar } from './HealthBar';
import pixi = CG.Pixi.pixi;
export class GameUILayer extends PIXI.Container {
// 玩家血條
private _healthBar: HealthBar;
constructor(private _game: Game) {
super();
const textures = pixi.assets.getSpritesheet("LittleWitch_TheJourney.圖集動畫.uis").textures;
// 愛心圖示
const healthIcon = new PIXI.Sprite({
texture: textures["heart_white"],
anchor: 0.5,
tint: 0xFF0000 // 調整為紅色
});
healthIcon.position.set(
10 + healthIcon.texture.width * 0.5,
10 + healthIcon.texture.height * 0.5
);
this.addChild(healthIcon);
const healthBar = this._healthBar = new HealthBar();
healthBar.position.set(
healthIcon.x + healthIcon.width * 0.5 + 10 + healthBar.barWidth * 0.5,
healthIcon.y
);
this.addChild(healthBar);
}
// 玩家血條
get healthBar(): HealthBar { return this._healthBar }
}
Game
:將 UI 綁定到玩家最後,我們要在主遊戲類別 Game
中,創建 GameUILayer
,並將小女巫綁定到 UI 圖層的血條物件上。
// ... (其餘省略)
import { GameUILayer } from './Uis/GameUILayer';
export class Game extends PIXI.Container {
// ... (其餘省略)
// UI 圖層
private _uiLayer: GameUILayer;
// ... (其餘省略)
constructor() {
super();
// ... (其餘初始化 不變)
// UI 圖層(用於加入各種 UI,如血條、經驗值、計時器等)
const uiLayer = this._uiLayer = new GameUILayer(this);
this.addChild(uiLayer);
// ... (創建小女巫實例 不變)
// 設置小女巫與玩家血條連動
uiLayer.healthBar.setCharacter(witch);
// ... (其餘省略)
}
// 小女巫
get witch(): Witch { return this._witch };
// ... (其餘省略)
}
由於添加了新的素材,因此 app.ts
start()
的 pixi.assets
也要添加 "LittleWitch_TheJourney.圖集動畫.uis"
喔!
今天是個非常重要的里程碑,我們今天成功地將遊戲的 戰鬥迴路 提升到了更完整的階段,讓遊戲真的可以玩了!
我們不僅處理了女巫的防禦機制,也提供了清晰的視覺回饋:
Witch.takeDamage()
,成功實作了核心的 無敵幀(IFrame) 邏輯,防止角色被連續傷害秒殺,並以經典的閃爍效果作為視覺提示。Witch.update()
中,加入了主動對所有敵人的 AABB 矩形碰撞檢測,完成敵人攻擊觸發的傷害流程。ProgressBar
框架,並讓 HealthBar
透過監聽 BaseCharacter.EVENT.HURT
事件來更新進度。這種事件連動的方式讓遊戲邏輯與 UI 完美解耦。HealthBar
使用了 TWEEN
(補間動畫)來平滑過渡血量變化,極大地提高了玩家界面的精緻感和遊戲體驗。遊戲現在已經有了完整的攻擊、受傷、防禦和生命值視覺化回饋。明天,我們將乘勝追擊,加入 升級系統與技能選擇 的核心機制,讓小女巫踏上變強的旅程!