iT邦幫忙

2025 iThome 鐵人賽

DAY 22
1

昨天我們完成了小女巫對敵人的傷害處理,但作為一個 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" 喔!

小女巫 玩家受傷、無敵幀 預覽

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

▸ 總結

今天是個非常重要的里程碑,我們今天成功地將遊戲的 戰鬥迴路 提升到了更完整的階段,讓遊戲真的可以玩了!

我們不僅處理了女巫的防禦機制,也提供了清晰的視覺回饋:

  • 完整防禦機制:透過複寫 Witch.takeDamage(),成功實作了核心的 無敵幀(IFrame) 邏輯,防止角色被連續傷害秒殺,並以經典的閃爍效果作為視覺提示。
  • 角色對敵人碰撞:在 Witch.update() 中,加入了主動對所有敵人的 AABB 矩形碰撞檢測,完成敵人攻擊觸發的傷害流程。
  • 事件驅動 UI:設計了可重用的 ProgressBar 框架,並讓 HealthBar 透過監聽 BaseCharacter.EVENT.HURT 事件來更新進度。這種事件連動的方式讓遊戲邏輯與 UI 完美解耦。
  • 視覺精緻度提升HealthBar 使用了 TWEEN(補間動畫)來平滑過渡血量變化,極大地提高了玩家界面的精緻感和遊戲體驗。

遊戲現在已經有了完整的攻擊、受傷、防禦和生命值視覺化回饋。明天,我們將乘勝追擊,加入 升級系統與技能選擇 的核心機制,讓小女巫踏上變強的旅程!


上一篇
Day 21:基礎碰撞 - 子彈 vs 敵人與生命值(HP)
下一篇
Day 23:簡易升級系統與技能選擇(一) - 遊戲暫停與升級介面架構
系列文
用 PixiJS 寫遊戲!告別繁瑣設定,在 Code.Gamelet 打造你的第一個遊戲23
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言