昨天,我們成功讓小女巫學會了自由飛行。但一位優秀的巫師在戰鬥中只會閃躲可不行!今天我們要為她裝備火力,實作她的核心戰鬥機制——自動攻擊,發射她的第一顆魔法彈!
先說說我們今天要做的事情:
MagicBullet 類別:讓子彈能夠移動並在飛出畫面時自動銷毀。Witch 類別發射子彈:設定計時器,讓女巫定期發出攻擊事件。start() 函數中監聽事件並生成魔法彈,並確保所有魔法彈都能在主循環中更新位置。MagicBullet 魔法彈類別我們將魔法彈的行為(移動、銷毀)封裝在一個 MagicBullet 類別中,讓它能像昨天的 Witch 一樣獨立運作。
// 定義生成魔法彈時需要的資料
interface IMagicBulletData {
	pos: { x: number, y: number };
	dir: { x: number, y: number };
	scale: number;
}
class MagicBullet extends PIXI.Container {
	// 魔法彈的 Sprite 物件
	private _sprite: PIXI.Sprite;
	constructor(private _data: IMagicBulletData) {
		super();
		// 從特效圖集中取得魔法彈紋理
		const texture = pixi.assets.getSpritesheet("ironman2025_cook.圖集動畫.特效").textures["magic_bullet"];
		const spirte = this._sprite = new PIXI.Sprite({
			texture: texture,
			anchor: 0.5,
			scale: 0.5 // 這個素材圖片稍大,預設縮小成 0.5
		});
		this.addChild(spirte);
		// 將子彈的初始位置設定為發射點
		this.position.copyFrom(_data.pos);
		this.scale.set(0);
		// 讓魔法彈生成時有一個微小的縮放淡入動畫,增加視覺效果
		new TWEEN.Tween(this.scale).to({ x: _data.scale, y: _data.scale }, 50)
			.easing(TWEEN.Easing.Cubic.Out).start();
	}
	/**
	 * 負責魔法彈的移動與銷毀邏輯。
	 */
	public update(dt: number): void {
		const { dir } = this._data;
		// 1. 根據方向向量和 dt 更新位置
		this.x += dir.x * dt;
		this.y += dir.y * dt;
		// 2. 邊界檢查與銷毀(當子彈飛出畫面時)
		const width = pixi.stageWidth;
		const height = pixi.stageHeight;
		// 取得子彈的半寬/半高
		const halfWidth = this._sprite.width * this.scale.x * 0.5;
		const halfHeight = this._sprite.height * this.scale.y * 0.5;
		// 若魔法彈超出任意邊界時銷毀 (使用半寬/高來確保整個子彈離開畫面)
		if (this.x < -halfWidth ||
			this.x > width + halfWidth ||
			this.y < -halfHeight ||
			this.y > height + halfHeight) {
			// 呼叫 destroy 釋放記憶體
			this.destroy({ children: true });
		}
	}
}
MagicBullet 實例時,程式編輯器(IDE)可以更好的告訴我應該傳入什麼參數。"ironman2025_cook.圖集動畫.特效" 裡面整合,雖然目前只有魔法彈,不過之後應該也會再多一些敵方的攻擊特效、技能等等。update() 函數中,子彈會檢查自己的位置,一旦飛出畫面邊界,就立即呼叫 this.destroy() 銷毀自己,避免記憶體洩漏。Witch 定期發射魔法彈// 承接 Day 17 的 Witch 類別,只顯示新增的部分
class Witch extends PIXI.Container {
	// 定義靜態事件名稱,方便使用
	static readonly EVENT = {
		ATTACK: "witch_attack"
	}
	// 攻擊間隔時間(毫秒)
	private _attackInterval: number = 1000;
	// 攻擊計時器(單位:毫秒)
	private _attackTimer: number = 0;
	// 子彈速度
	private _bulletSpeed = { x: 0.3, y: 0 };
	// ... (constructor 和其他屬性不變)
	/**
	 * 發射魔法彈。
	 */
	private _launchMagicBullet(): void {
		this.emit(Witch.EVENT.ATTACK, {
			// 將魔法彈的發射位置偏移至掃帚的頭部
			pos: { x: this.x + 50, y: this.y + 31 },
			dir: this._bulletSpeed,
			scale: 1
		} as IMagicBulletData);
	}
	update(dt: number): void {
		// ... (移動邏輯不變)
		// 1. 攻擊計時
		this._attackTimer += dt;
		if (this._attackTimer >= this._attackInterval) {
			this._attackTimer = 0; // 重置計時器
			// 2. 發射魔法彈
			this._launchMagicBullet();
		}
	}
}
_attackInterval 時,我們呼叫 this.emit() 向外發出一個自定義事件。目的是為了讓子彈的運作與女巫本身解耦,簡單的說,就是 Witch 只負責「發起攻擊」,而不負責「子彈的生命週期」,這樣程式碼才不會過於複雜,讓程式碼職責更單一。_launchMagicBullet() 中,我們計算了一個稍微偏離小女巫中心的發射點,讓子彈看起來是從掃帚末端發射出來的。現在我們需要調整 start() 函數來連接 Witch 的事件與 MagicBullet 的實例化。
// ... (import 不變)
async function start() {
	// 將專案資源載入至遊戲中,並等待載入完成。(記得將資源別名修改成你自己專案的資源別名喔!)
	await pixi.assets
		// ... (其他素材不變)
		.add("ironman2025_cook.圖集動畫.特效") // 添加特效資源
		.load();
	// ... (初始化不變)
	// 特效圖層(用於加入各種場景特效,如魔法彈、敵人技能等)
	const effectLayer = new PIXI.Container();
	pixi.root.addChild(effectLayer);
	// ... (witch 創建不變)
    
	// 在外部接收小女巫攻擊事件,生成魔法彈並加入特效圖層
	witch.on(Witch.EVENT.ATTACK, (data: IMagicBulletData) => {
		const bullet = new MagicBullet(data);
		effectLayer.addChild(bullet);
	}, this);
	// 設置更新循環函數,每一幀都呼叫背景的 update 函式
	CG.Base2.addUpdateFunction((dt: number) => {
		// ... (其他 update() 不變)
		// 歷遍 effectLayer 的所有子物件去執行 update()
		const children = effectLayer.children;
		for (let i = children.length - 1; i >= 0; --i) {
			const effect = children[i];
			(effect as MagicBullet).update(dt);
		}
	});
}
effectLayer:我們創建了一個獨立的 PIXI.Container 作為「特效圖層」,用於將所有的特效整合在同一個圖層上,也方便管理。for (let i = children.length - 1; i >= 0; --i) 倒序迴圈。這是因為當子彈飛出畫面時,它會呼叫 destroy() 將自己銷毀,並從 effectLayer.children 陣列中移除。如果我們使用正序迴圈,移除元素會導致陣列長度改變和後續元素的索引錯亂,而倒序迴圈就不會有這個問題。
我們今天成功為小女巫加入了自動攻擊能力,並透過 Tween 讓發射的魔法彈多了一點視覺上的小驚喜。
現在我們有了:
Witch 和 MagicBullet 兩個獨立類別中。然而,有一個非常嚴重的問題開始慢慢浮出水面了。我們的 start() 函數開始變得越來越肥,越來越種了。它不僅要負責初始化所有物件,還要管理各種圖層(背景、特效)以及子彈的更新迴圈。當我們開始加入敵人和碰撞邏輯時,start() 將會不堪重負!
所以明天!我打算先來實作一個 Game 類別。這個類別將肩負起整合所有物件、管理圖層、處理遊戲流程的重責大任,讓我們的專案架構瞬間變得清晰!