昨天,我們成功讓小女巫學會了自由飛行。但一位優秀的巫師在戰鬥中只會閃躲可不行!今天我們要為她裝備火力,實作她的核心戰鬥機制——自動攻擊,發射她的第一顆魔法彈!
先說說我們今天要做的事情:
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
類別。這個類別將肩負起整合所有物件、管理圖層、處理遊戲流程的重責大任,讓我們的專案架構瞬間變得清晰!