昨天我們完成了道具系統以及受傷閃白的效果,今天我們要來實作整個遊戲最高潮的部分:Boss!
由於 Boss 肯定會有許多不同於一般敵人的邏輯,因此我打算讓 Boss 繼承 Enemy 變成一個獨立的 BossEnemy 類別,但在這之前需要先針對 BaseCharacter、Enemy 做一些調整,讓繼承後的 BossEnemy 可以更好的實作其他細節。
這邊我就簡單說明就好,不再展示程式碼了:
BaseCharacter:
_playDeathAnimation() 函數,death() 改成呼叫此函數來播放動畫,這樣繼承後的子類別就可以自己複寫播放動畫的函數,來演出不同的死亡動畫,但又不會改變死亡的流程。DEATH_END 事件,在播放完死亡動畫且準備 destroy() 前發送,用於未來在小女巫、Boss 死亡時結束遊戲做準備。_createTime 保護屬性,於建構子設為 game.elapsedTime,讓子類別可以實作一些基於創建時間的動畫演出等。team 屬性與 setTeam() 函數,讓角色可以設定陣營,未來可以用於區分敵我。主要是為了讓敵我都可以發射魔法彈。_onSpawn() 保護函數,於建構子呼叫,讓子類別可以覆寫此函數來實作出生邏輯。ICharacterOptions 介面,讓 _onSpawn() 之前就已經初始化完紋理等屬性,避免子類別覆寫 _onSpawn() 時讀取不到寬高等屬性。Enemy:
game.addEnemy() 裡設定敵人位置的邏輯移到 _onSpawn(),因為未來 Boss 生成位置不一樣。另外還有關於 Boss 的 Type 定義,IEnemyConfig 添加了一個 enemyClass 屬性,讓我們可以指定這個敵人要使用哪一個類別來生成,這樣就可以讓 Boss 使用 BossEnemy 類別來生成了。
因為這個調整,Game.addEnemy() 也需要做一些修改,來根據 enemyType.config.enemyClass 來決定要生成哪一個類別的敵人。
由於目前的架構導致
EnemyType和Enemy互相引用,似乎出現了一些循環依賴的問題,導致執行時程式找不到Enemy類別等,我目前還沒搞清楚實際原因,
但我目前的解法是直接在app.ts裡面事先import EnemyType,未來有機會在想辦法優化這個部分。
BossEnemy:建立基本結構import { Game } from './../../Game';
import { Enemy } from "./Enemy";
import { EnemyType } from "./EnemyType";
import pixi = CG.Pixi.pixi;
export class BossEnemy extends Enemy {
    // 正在動畫中
    private _isAnimating: boolean = true;
    /**
     * 當敵人生成時。
     */
    _onSpawn(): void {
        // 將 Boss 設定在畫面右邊外,垂直置中
        const halfWidth = this.width * 0.5;
        this.position.set(
            pixi.stageWidth + halfWidth,
            pixi.stageHeight * 0.45
        );
        this._playEnterAnimation();
    }
    /**
     * 受到傷害。
     * @param damage - 傷害數值
     */
    takeDamage(damage: number): void {
        if (this._isAnimating) return;
        super.takeDamage(damage);
    }
    /**
     * 更新循環函數。
     * @param dt - 每幀間隔時間(ms)
     */
    update(dt: number): void {
        // 如果已經死亡則直接結束
        if (!this.isAlive) return;
        const elapsedTime = this.game.elapsedTime - this._createTime;
        // _sprite 微微上下浮動
        const timing = elapsedTime % 3000 / 3000;
        this._sprite.y = Math.sin(timing * Math.PI * 2) * 5;
    }
    /**
     * 播放進場動畫。
     * - Boss 從畫面右邊飛進來,然後停在畫面右側約 90% 的位置。
     */
    async _playEnterAnimation(): Promise<void> {
        const tween = new TWEEN.Tween(this)
            .to({ x: pixi.stageWidth * 0.9 }, 3000)
            .easing(TWEEN.Easing.Sinusoidal.Out);
        await this.game.startTween(tween);
        await this.game.uiLayer.setBoss(this);
        this._isAnimating = false
    }
    /**
     * 播放死亡動畫。
     * 1. 先稍微往後倒,並左右搖晃
     * 2. 然後往下掉落畫面外
     */
    async _playDeathAnimation(): Promise<void> {
        this._isAnimating = true;
        new TWEEN.Tween(this._sprite)
            .to({ rotation: Math.PI / 180 * 30 }, 500)
            .easing(TWEEN.Easing.Sinusoidal.Out)
            .start();
        await this.game.startTween(new TWEEN.Tween(this)
            .to({ x: this.x + 20 }, 40)
            .yoyo(true)
            .repeat(8)
            .easing(TWEEN.Easing.Sinusoidal.InOut));
        await this.game.startTween(new TWEEN.Tween(this)
            .to({ y: pixi.stageHeight + this.height }, 2000)
            .easing(TWEEN.Easing.Cubic.In));
        this._isAnimating = false;
    }
}
_onSpawn(),將 Boss 位置設在畫面右邊外,垂直置中,然後呼叫 _playEnterAnimation() 播放進場動畫。game.uiLayer.setBoss(this) 來顯示 Boss 血條。update() 裡面讓 Boss 的 Sprite 微微上下浮動。_playDeathAnimation(),讓 Boss 先左右搖晃,然後往下掉落畫面外。uiLayer.setBoss:為了讓 Boss 有獨立的血條,我在 GameUILayer 裡面添加了 setBoss() 函數,來設置 Boss 血條並且有填滿動畫。

BossEnemy:實作技能系統架構export class BossEnemy extends Enemy {
    // ... (其餘省略)
    // 正在攻擊中
    private _isAttacking: boolean = false;
    // 當前的 Tween 動畫
    private _currTween: TWEEN.Tween<any>;
    // 技能列表(用於定義技能的使用條件、冷卻時間、權重等)
    private _skills: Array<{
        checkCanUse: () => boolean,
        use: () => Promise<void>,
        cooldown: number,
        lastUsedTime?: number,
        weight: number
    }> = [];
    /**
     * 更新循環函數。
     * @param dt - 每幀間隔時間(ms)
     */
    update(dt: number): void {
        // ... (其餘省略)
        // 如果正在播放動畫則結束
        if (this._isAnimating) return;
        // 如果沒有正在攻擊中,則嘗試發動攻擊
        if (!this._isAttacking) {
            // 篩選出可以使用的技能
            const availableSkills = this._skills.filter(skill => {
                if (!skill.checkCanUse()) return false;
                if (typeof skill.lastUsedTime === "number") {
                    return (this.game.elapsedTime - skill.lastUsedTime) >= skill.cooldown;
                }
                return true;
            });
            if (availableSkills.length > 0) {
                // 計算總權重
                const totalWeight = availableSkills.reduce((sum, skill) => sum + skill.weight, 0);
                // 產生一個 0 到 totalWeight 之間的隨機數
                let random = Math.random() * totalWeight;
                // 根據權重選擇技能
                for (const skill of availableSkills) {
                    if (random < skill.weight) {
                        // 使用該技能
                        skill.lastUsedTime = this.game.elapsedTime;
                        this._isAttacking = true;
                        skill.use().then(() => {
                            this._isAttacking = false;
                        });
                        break;
                    }
                    random -= skill.weight;
                }
            }
        }
    }
    /**
     * 開始一個 Tween 動畫,並且記錄目前的動畫狀態。如果角色已經死亡,則直接略過。
     * @param tween - 要執行的 Tween 動畫
     */
    startTween(tween: TWEEN.Tween<any>): Promise<void> {
        if (this.isAlive) {
            this._currTween = tween;
            return this.game.startTween(tween).then(() => { 
                this._currTween = null;
            });
        }
        return Promise.resolve();
    }
	/**
	 * 讓角色死亡。
     * @override 覆寫 BaseCharacter 的死亡函數,添加停止動畫
	 */
    async death(): Promise<void> {
        if (!this.isAlive) return;
        // 停止目前的動畫
        this._currTween?.stop();
        this._isAttacking = false;
        
        return super.death();
    }
}
_currTween 屬性來記錄目前正在執行的 Tween,並且在 death() 裡面停止它,避免角色死亡後動畫還在跑。_skills 陣列來管理技能,每個技能包含:
checkCanUse:一個回傳布林值的函數,用於檢查這個技能是否可以使用(例如:血量低於多少、距離目標多遠等條件)。use:一個回傳 Promise 的函數,當技能被選中時會呼叫它來執行技能邏輯。cooldown:技能的冷卻時間(毫秒)。lastUsedTime:上次使用這個技能的時間戳(毫秒),用於計算冷卻。weight:技能的權重,用於隨機選擇技能時的機率計算。update() 裡面,如果沒有正在攻擊中且沒有播放動畫,則會篩選出可以使用的技能,根據權重隨機選擇一個技能來執行。簡單的說,這就是一個基於條件、冷卻、權重的技能系統架構,接下來我們就可以來實作具體的技能了。
BossEnemy:定義技能一個會往小女巫的方向,發射三個散射魔法彈的攻擊。
private async _shootMagicBullets(): Promise<void> {
    // 讓中間的魔法彈對準女巫
    const witch = this.game.witch;
    // 發射位置
    const pos = { x: this.x - 130, y: this.y - 30 };
    const bulletSpeed = 0.3;
    const bullets = 3;
    const angleStep = Math.PI / 180 * 15; // 每顆魔法彈的角度間隔(度數)
    // 面向小女巫的角度
    const witchAngle = Math.atan2(witch.y - pos.y, witch.x - pos.x);
    for (let i = 0; i < bullets; ++i) {
        const angle = witchAngle - (angleStep * (bullets - 1) * 0.5) + (angleStep * i);
        const dir = {
            x: Math.cos(angle) * bulletSpeed,
            y: Math.sin(angle) * bulletSpeed
        }
        const magicBullet = new MagicBullet(this.game, {
            pos: pos,
            dir: dir,
            scale: 1.3,
            damage: 1,
            owner: this
        });
        this.game.effectLayer.addChild(magicBullet);
    }
    // 讓這個技能等待 1000 毫秒才結束(之後才可以執行下個技能)
    await this.game.wait(1000);
}

private async _dashLeft(): Promise<void> {
	const witch = this.game.witch;
	const hitBox = this.hitBox as PIXI.Rectangle;
	// 記錄原本位置
	const originalPos = { x: this.x, y: this.y };
	const targetY = witch.y;
	const targetX = -hitBox.x * 1.5; // 衝撞到畫面左側
	// 移動到目標高度
	await this.startTween(
		new TWEEN.Tween(this)
			.to({ x: this.x + 50, y: targetY }, 2000)
			.easing(TWEEN.Easing.Sinusoidal.Out)
	);
	// 衝撞到畫面左側
	await this.startTween(
		new TWEEN.Tween(this)
			.to({ x: targetX }, 1000)
			.easing(TWEEN.Easing.Cubic.In)
	);
    // 搖晃動畫
	await this.startTween(
		new TWEEN.Tween(this._sprite)
			.to({ x: 10 }, 40)
			.yoyo(true)
			.repeat(6)
			.easing(TWEEN.Easing.Sinusoidal.InOut)
	);
	// 回到原本位置
	await this.startTween(
		new TWEEN.Tween(this)
			.to(originalPos, 2000)
			.easing(TWEEN.Easing.Sinusoidal.InOut)
	);
}
Tween:這個技能的邏輯主要是使用 Tween 來移動 Boss 的位置,並且使用上方添加的 startTween() 函數,來確保如果角色死亡,動畫會被停止。因此實際上這是一個通過 Boss 本身的碰撞箱來對小女巫造成傷害的技能。
constructor(game: Game, type: EnemyType) {
	super(game, type);
	this._skills.push({
		checkCanUse: () => true,
		use: this._shootMagicBullets.bind(this),
		cooldown: 2000,
		weight: 5
	}, {
		checkCanUse: () => this.hp / this.maxHp < 0.5 // 生命低於 50% 才會使用
		use: this._dashLeft.bind(this),
		cooldown: 15000,
		weight: 1
	});
}
針對每個技能,設定它的使用條件、冷卻時間、權重等。
如此以來,整個 Boss 的就完成了!包含:
Game 的 addEnemy() 添加敵人的物件圖層,讓新加入的敵人會在最下方,避免其他敵人,如 Boss 背後來的小怪擋住。Enemy、Witch,讓它們也有浮動效果,讓整個遊戲畫面的質感又更加提升了。_playDeathAnimation() 函數,因此我也讓小女巫有特別的死亡動畫了,她會像是以前的超級瑪利歐一樣,停頓一下,然後一邊逆時針轉圈圈,一邊往畫面下方掉出去。Game 裡面會監聽小女巫與 Boss 的 DEATH_END 事件,兩者任一死亡都會暫停遊戲,為未來顯示結束畫面做準備。startTween() 的時候會直接執行 Tween,但有可能會在 updater 已經被暫停的時候呼叫,因此改成執行後會判斷是否暫停中,同步停止 Tween。
今天又是一個重要的里程碑,我們完成了這個遊戲最重要的目標之一,成功讓 Boss 登場並且有自己的技能系統。
BossEnemy 類別:繼承自 Enemy,並且覆寫了一些方法來實作 Boss 的特殊行為,如進場動畫、死亡動畫等。雖然我只添加了兩個技能,但這個架構讓我們可以很容易地添加更多的技能,並且根據遊戲需求來調整它們的行為。
到此為止,整個遊戲從開始到結束的主要流程已經完成了,目前我們的《小女巫・啟程》已經有:
明天,我們將進入遊戲的最後階段,開始實作計時器、結算界面以及音效,讓整個遊戲更加完整!