iT邦幫忙

2025 iThome 鐵人賽

DAY 26
1
Modern Web

用 PixiJS 寫遊戲!告別繁瑣設定,在 Code.Gamelet 打造你的第一個遊戲系列 第 26

Day 26:拯救世界的路上怎麼可以少了大魔王?BOSS 登場!

  • 分享至 

  • xImage
  •  

昨天我們完成了道具系統以及受傷閃白的效果,今天我們要來實作整個遊戲最高潮的部分:Boss

▸ 事前優化

由於 Boss 肯定會有許多不同於一般敵人的邏輯,因此我打算讓 Boss 繼承 Enemy 變成一個獨立的 BossEnemy 類別,但在這之前需要先針對 BaseCharacterEnemy 做一些調整,讓繼承後的 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 來決定要生成哪一個類別的敵人。

由於目前的架構導致 EnemyTypeEnemy 互相引用,似乎出現了一些循環依賴的問題,導致執行時程式找不到 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() 播放進場動畫。
  • 進場動畫:Boss 會從畫面右邊外緩慢移動到畫面內,並在抵達後呼叫 game.uiLayer.setBoss(this) 來顯示 Boss 血條。
  • 浮動效果:在 update() 裡面讓 Boss 的 Sprite 微微上下浮動。
  • 死亡動畫:覆寫 _playDeathAnimation(),讓 Boss 先左右搖晃,然後往下掉落畫面外。
  • uiLayer.setBoss:為了讓 Boss 有獨立的血條,我在 GameUILayer 裡面添加了 setBoss() 函數,來設置 Boss 血條並且有填滿動畫。

小女巫 BOSS 進場動畫 預覽
小女巫 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();
    }
}
  • 額外的 Tween 管理:由於攻擊技能可能會有自己的 Tween 動畫,因此我添加了一個 _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);
}
  • 發射位置:上方的偏移位置,是根據 Boss 的圖片來調整的,讓魔法彈從 Boss 的眼睛位置發射。

小女巫 BOSS 魔法彈攻擊 預覽

二、衝撞攻擊

  1. 先移動到小女巫的高度,同時稍微向後退。
  2. 往畫面左側衝撞後,稍微震動。
  3. 慢慢飛回原本的右側位置。
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 本身的碰撞箱來對小女巫造成傷害的技能。

小女巫 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 的就完成了!包含:

  • 進場動畫:從畫面右側飛進來,並且顯示血條。
  • 技能系統:基於條件、冷卻、權重的技能系統架構。
  • 魔法彈攻擊:發射三個散射魔法彈攻擊小女巫。
  • 衝撞攻擊:衝撞到畫面左側,並且對小女巫造成傷害。
  • 死亡動畫:左右搖晃後往下掉落畫面外。

▸ 其他優化

  • 敵人圖層優化:調整了 GameaddEnemy() 添加敵人的物件圖層,讓新加入的敵人會在最下方,避免其他敵人,如 Boss 背後來的小怪擋住。
  • 浮動效果:因為 Boss 添加了浮動效果,因此我也順便調整了 EnemyWitch,讓它們也有浮動效果,讓整個遊戲畫面的質感又更加提升了。
  • 小女巫死亡動畫:由於添加了 _playDeathAnimation() 函數,因此我也讓小女巫有特別的死亡動畫了,她會像是以前的超級瑪利歐一樣,停頓一下,然後一邊逆時針轉圈圈,一邊往畫面下方掉出去。
  • 結束流程:現在 Game 裡面會監聽小女巫與 Boss 的 DEATH_END 事件,兩者任一死亡都會暫停遊戲,為未來顯示結束畫面做準備。
  • GameUpdater:原本在 startTween() 的時候會直接執行 Tween,但有可能會在 updater 已經被暫停的時候呼叫,因此改成執行後會判斷是否暫停中,同步停止 Tween

小女巫 死亡動畫 預覽

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

▸ 總結

今天又是一個重要的里程碑,我們完成了這個遊戲最重要的目標之一,成功讓 Boss 登場並且有自己的技能系統。

  • BossEnemy 類別:繼承自 Enemy,並且覆寫了一些方法來實作 Boss 的特殊行為,如進場動畫、死亡動畫等。
  • 技能系統架構:設計了一個基於條件、冷卻、權重的技能系統,讓 Boss 可以根據當前狀態選擇適合的技能來使用。
  • 具體技能實作:實作了兩個技能,分別是發射魔法彈和衝撞攻擊,並且將它們加入到技能列表中。

雖然我只添加了兩個技能,但這個架構讓我們可以很容易地添加更多的技能,並且根據遊戲需求來調整它們的行為。

到此為止,整個遊戲從開始到結束的主要流程已經完成了,目前我們的《小女巫・啟程》已經有:

  • 小女巫:作為玩家操控的角色,可以移動、攻擊、受傷、死亡。
  • 敵人:會從畫面右側生成,並且會移動到畫面左側攻擊小女巫。
  • 波數系統:我們可以通過定義簡單的 json 來設定每一波的敵人數量、類型、生成間隔等。
  • 升級系統:小女巫可以透過擊敗敵人來獲得經驗值並升級,並且在升級時可以選擇提升不同的屬性。
  • 道具系統:敵人死亡時有機率掉落各種道具,小女巫可以撿起來並獲得對應的效果。如經驗球、治療藥水。
  • Boss 戰:Boss 會在畫面右側登場,並且有自己的血條和技能系統,可以對小女巫造成威脅。

明天,我們將進入遊戲的最後階段,開始實作計時器、結算界面以及音效,讓整個遊戲更加完整!


上一篇
Day 25:道具掉落與受傷閃白(濾鏡應用)
系列文
用 PixiJS 寫遊戲!告別繁瑣設定,在 Code.Gamelet 打造你的第一個遊戲26
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言