iT邦幫忙

2025 iThome 鐵人賽

DAY 23
0
Modern Web

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

Day 23:簡易升級系統與技能選擇(一) - 遊戲暫停與升級介面架構

  • 分享至 

  • xImage
  •  

經過昨天實作血條 UI 後,我們已經具備了 ProgressBar 這個可重用元件。今天我們的目標是將這個元件用於經驗值(EXP),並實現 Roguelike 遊戲中極為關鍵的 遊戲暫停機制,以及 升級選項介面 的基礎排版與呼叫流程。但由於升級牽涉到的架構較為龐大,今天沒辦法一次做好,因此我打算拆到明天去,今天只做簡單的排版,以及觸發、結束的流程。

Constants:引入全局遊戲參數配置

在開始之前我們同樣的要先來進行一些小優化,建立一個全域常數的類別,將一些靜態屬性放在裡面,方便我們直接調整會影響整個遊戲的參數。

/** 全域常數 */
export class Constants {

	// 是否顯示碰撞箱預覽
	static HIT_BOX_PREVIEW: boolean = false;

}

GameObject:統一管理遊戲實例與開發輔助

有了全域常數之後,我們也要調整對應的程式碼,才能讓其發揮效用。

// ... (其餘不變)
import { Game } from './Game';

export class GameObject extends PIXI.Container {

	// ... (其餘不變)

	constructor(private _game: Game) {
		super();

		// ... (其餘不變)

		// 如果是開發模式,則建立碰撞箱預覽圖形
		if (CG.Base2.system.devMode && Constants.HIT_BOX_PREVIEW) {
			const hitBoxPreview = this._hitBoxPreview = new PIXI.Graphics();
			this.addChild(hitBoxPreview);
		}

	}

	// 遊戲實例
	get game(): Game { return this._game }

	// ... (其餘不變)

	/**
	 * 銷毀物件。
	 * @param options - 銷毀選項
	 * @override 添加額外的清理工作
	 */
	destroy(options?: PIXI.DestroyOptions): void {

		// 清除對遊戲實例的引用
		this._game = null;

		super.destroy(options);
	}
}
  • game:由於待會 Game 會新增一些所有物件都有可能會使用到的函數(startTween()),因此我讓所有的 GameObject 皆需要傳入 game

GameUpdater:實現遊戲的統一暫停與繼續

為了升級時可以讓遊戲暫停,我打算引入另外一個更新器,讓我們可以更方便的暫停、繼續遊戲。

// Games/GameUpdater.ts
import FPSUpdater = CG.Base2.utils.FPSUpdater;

export class GameUpdater extends FPSUpdater {

    // 正在進行的 tween 陣列
    private _tweens: TWEEN.Tween<any>[] = [];

    /**
     * 讓更新器暫停。
     * @override 添加暫停所有正在進行的 tween 功能。
     */
    pause(): void {
        super.pause();
        this._tweens.forEach(tween => tween.pause());
    }

    /**
     * 讓更新器繼續。
     * @override 添加繼續所有正在進行的 tween 功能。
     */
    resume(): void {
        super.resume();
        this._tweens.forEach(tween => tween.resume());
    }

    /**
     * 開始一個新的 tween。用於統一管理 tween,方便在暫停和繼續時控制它們。
     * @param tween - 要開始的 tween
     */
    startTween(tween: TWEEN.Tween<any>): Promise<void> {
        this._tweens.push(tween);
        return new Promise<void>(resolve => tween.onComplete(() => {
            const index = this._tweens.indexOf(tween);
            if (index !== -1) this._tweens.splice(index, 1);
            resolve();
        }).start());
    }

    /**
     * 釋放更新器。
     * @override 停止所有正在進行的 tween,並清空 tween 陣列。
     */
    dispose(): void {
        this._tweens.forEach(tween => tween.stop());
        this._tweens = null;
        super.dispose();
    }
}
  • FPSUpdater:我曾經在 Day 10 稍微提到過這個東西。它也有 addUpdateFunction 的功能,並且另外添加了暫停、繼續、查詢經過時間等功能。由於我們會需要在升級選擇畫面暫停遊戲,因此我將替換掉 CG.Base2.addUpdateFunction 全面改用此類別來運行。
  • 管理 Tween:為了確保在遊戲暫停時,所有正在進行的 補間動畫(Tween) 也能跟著暫停,我們在 GameUpdater 中新增了一個私有陣列 _tweens 來追蹤所有透過 startTween() 啟動的動畫。這樣,當呼叫 pause()resume() 時,就能統一控制這些動畫的狀態。

Game:整合新的更新器與暫停控制

// ... (其餘不變)
import { GameUpdater } from './GameUpdater';

export class Game extends PIXI.Container {
	
	// ... (其餘不變)
    
	// 循環更新器
	private _updater: GameUpdater = new GameUpdater(60);

	constructor() {
		super();

		// ... (其餘不變)
        
		// 設置小女巫與 UI 連動
		uiLayer.setWitch(witch);

		// ... (其餘不變)

	}

	// 開始遊戲後的經過時間(毫秒)
	get elapsedTime(): number { return this._updater.time }

	/**
	 * 開始一個補間動畫。
	 * @param tween - 要開始的補間動畫
	 */
	startTween(tween: TWEEN.Tween<any>): Promise<void> {
		return this._updater.startTween(tween);
	}

	/**
	 * 開始遊戲。
	 */
	start(): void {

		this._waveController.start();

		// 重設更新器的經過時間
		this._updater.setTimepassed(0);
		// 設置更新循環函數,每一幀都呼叫 update 函數
		this._updater.addUpdateFunction(this, this._update);
	}

	/**
	 * 添加敵人。
	 * @param enemyType - 敵人類型
	 */
	addEnemy(enemyType: EnemyType) {
    
		// ... (其餘不變)
        
        // 死亡事件與獲取經驗值在文章下方實作
		enemy.once(BaseCharacter.EVENT.DEATH, () => {
			this._witch.gainExp(enemy.type.config.exp);
		}, this);
	}

	/**
	 * 暫停遊戲。
	 */
	pause(): void {
		this._updater.pause();
	}
	
	/**
	 * 繼續遊戲。
	 */
	resume(): void {
		this._updater.resume();
	}

	/**
	 * 銷毀遊戲物件。
	 * @override 複寫 Container 的銷毀函數,處理額外清除邏輯,強制銷毀所有子物件
	 */
	destroy(): void {

		// 清除更新循環函數
		this._updater.removeUpdateFunction(this, this._update);
		this._updater = null;

		// 呼叫父類的 destroy 強制銷毀所有子物件
		super.destroy({ children: true });
	}
}
  • uiLayer.setWitch():由於遊戲中所有與玩家狀態相關的 UI(如血條、經驗值、升級介面)都需要綁定小女巫實例,所以我們等等在 GameUILayer 中會創建一個統一的 setWitch() 函數。這樣一來,所有子 UI 都能集中註冊事件監聽,保持 Game.ts 的乾淨與簡潔。
  • startTween:有了這個函數以後,所有的遊戲物件在運行 Tween 時,都應該通過此函數來啟動,下方 BaseCharacter 會示範一次如何使用,其他地方就不特別再重複了。

BaseCharacter:新增死亡事件與 Tween 統一管理

BaseCharacter 中,我們需要新增一個 DEATH 事件,並將死亡時的動畫呼叫改用 this.game.startTween,以納入 GameUpdater 的管理。

// ... (其餘省略)

export class BaseCharacter extends GameObject {

	// 定義靜態事件名稱,方便使用
	static EVENT = {
		HURT: "hurt",
		DEATH: "death"
	}

	// ... (其餘省略)

	/**
	 * 讓角色死亡。
	 */
	async death(): Promise<void> {
		if (!this._isAlive) return;
		this._hp = 0;
		this._isAlive = false;
		this.emit(BaseCharacter.EVENT.DEATH); // 發送角色死亡事件
        // 為了統一管理,改用 this.game.startTween 來啟動 Tween
		await this.game.startTween(new TWEEN.Tween(this).to({ alpha: 0 }, 200)
			.easing(TWEEN.Easing.Cubic.Out));
		this.destroy({ children: true })
	}
}

Witch:實作經驗值屬性與升級邏輯

我們需要讓小女巫添加經驗值的屬性,以及獲得經驗值的功能,並且可以發送對應的事件,讓後續的經驗條 UI 能夠監聽並改變經驗值進度。

// ... (其餘不變)

export class Witch extends BaseCharacter {

	// 定義靜態事件名稱,方便使用
	static EVENT = {
		// ... (其餘不變)
		GAIN_EXP: "witch_gain_exp",
		LEVEL_UP: "witch_level_up"
	}

	// ... (其餘不變)

	// 等級
	private _level: number = 1;
	// 經驗值
	private _exp: number = 0
	// 升級所需經驗值
	private _maxExp: number = 10;

	// 等級
	get level(): number { return this._level }
	// 經驗值
	get exp(): number { return this._exp }
	// 升級所需經驗值
	get maxExp(): number { return this._maxExp }

	/**
	 * 獲得經驗值。
	 * @param exp - 經驗值數量
	 */
	gainExp(exp: number): void {
		this._exp += exp;
		const oldLevel = this._level;
		// 檢查是否達到升級條件
		while (this._exp >= this._maxExp) {
			this._exp -= this._maxExp;
			this._level++;
			this._maxExp += 10; // 每次升級所需經驗值增加 10
		}
		// 發出獲得經驗值事件
		this.emit(Witch.EVENT.GAIN_EXP);
		// 如果等級有提升,則發出升級事件
		if (this._level > oldLevel) {
			this.emit(Witch.EVENT.LEVEL_UP, { newLevel: this._level, oldLevel: oldLevel });
		}
	}

	// ... (其餘不變)
}

ExpBar:將經驗值視覺化於畫面頂端

我打算讓經驗條鋪滿在畫面正上方,並且使用事件連動的方式,確保當小女巫獲得經驗值或升級時,經驗條能夠正確更新。

import { ProgressBar } from './ProgressBar';
import pixi = CG.Pixi.pixi;
import { Witch } from '../Characters/Witch';

export class ExpBar extends ProgressBar {

    // 與經驗條連動的小女巫
    private _witch: Witch;

    constructor() {
        super({
            width: pixi.stageWidth,
            height: 5,
            color: 0xFFFF00,
            progress: 0
        });

    }

    private _onWitchGainExp(): void {
        const { exp, maxExp } = this._witch;
        this.progress = exp / maxExp;
    }

    /**
     * 設定與經驗條連動的小女巫。
     * @param witch - 小女巫
     */
    setWitch(witch: Witch): void {
        const oldWitch = this._witch;
        // 如果有舊的小女巫先清除事件監聽
        if (oldWitch) {
            oldWitch.off(Witch.EVENT.GAIN_EXP, this._onWitchGainExp, this);
        }
        // 設置事件監聽並立即更新當前經驗條進度
        this._witch = witch;
        witch.on(Witch.EVENT.GAIN_EXP, this._onWitchGainExp, this);
        this.progress = witch.exp / witch.maxExp;
    }
}

GameUILayer:統一 UI 介面與小女巫的連動

加入今天新添加的經驗條,以及待會要新增的升級介面,並添加 setWitch 用於統一設置小女巫與 UI 連動。

// ... (其餘不變)
import { ExpBar } from './ExpBar';
import { Witch } from '../Characters/Witch';
import { LevelUpLayer } from './LevelUpLayer';

export class GameUILayer extends PIXI.Container {

	// ... (其餘不變)
    
	// 玩家經驗條
	private _expBar: ExpBar;

	// 升級介面
	private _levelUpLayer: LevelUpLayer;

	constructor(private _game: Game) {
		super();

		// ... (其餘不變)

		// 玩家經驗條
		const expBar = this._expBar = new ExpBar();
		expBar.x = pixi.stageWidth * 0.5;
		this.addChild(expBar);
		
		// 升級介面
		const levelUpLayer = this._levelUpLayer = new LevelUpLayer(this._game);
		this.addChild(levelUpLayer);
		levelUpLayer.visible = false;
	}

	/**
	 * 設置小女巫與 UI 連動。
	 * @param witch - 小女巫
	 */
	setWitch(witch: Witch): void {
		// 設置小女巫與玩家血條連動
		this._healthBar.setCharacter(witch);
		// 設置小女巫與經驗條連動
		this._expBar.setWitch(witch);
		// 設置小女巫與升級介面連動
		this._levelUpLayer.setWitch(witch);
	}
}

LevelUpLayer:架構升級選項介面與流程控制

升級後,從三個提升屬性的選項裡選擇其中一個,這聽起來是不是很熟悉。沒錯!就是我們第一階段的最後一天,Day 14 所實作的幸運餅乾三選一!

不過幸運餅乾是著重在動畫演出的 demo,很單純的選其中一個跳出一段訊息而已。但今天這個牽扯到的東西就更多了,不再是單純的紙條,每個選項背後要做的事情都不同,甚至出現的選項也可能會因為玩家的狀態而有所不同。例如玩家持有的武器、道具等,玩家武器的等級不同,提升的效果可能也不一樣。

但今天已經是第 23 天了,我們只剩下 7 天不到的時間,還有許多像是道具掉落、敵人受傷特效、音效,以及最酷炫的 BOSS 戰等功能都還沒做出來,因此我們應該不會弄得太複雜,頂多添加小女巫的屬性、讓魔法彈多射一顆之類的。

// Games/Uis/LevelUpLayer.ts
import { Game } from "../Game";
import pixi = CG.Pixi.pixi;
import { Witch } from "../Characters/Witch";

export class LevelUpLayer extends PIXI.Container {

    // 與升級介面連動的小女巫
    private _witch: Witch;

    // 半透明背景
    private _background: PIXI.Graphics;
    // 介面標題
    private _titleSprite: PIXI.Sprite;

    private _choices: PIXI.Container[] = [];

    constructor(private _game: Game) {
        super();

        const background = this._background = new PIXI.Graphics()
            .rect(0, 0, pixi.stageWidth, pixi.stageHeight)
            .fill({ color: 0x000000, alpha: 0.4 } as PIXI.FillStyle);
        this.addChild(background);

        const textures = pixi.assets.getSpritesheet("LittleWitch_TheJourney.圖集動畫.uis").textures;

        // 介面標題
        const titleSprite = this._titleSprite = new PIXI.Sprite({
            texture: textures["level_up_title_box"],
            anchor: 0.5,
            position: { x: pixi.stageWidth * 0.5, y: pixi.stageHeight * 0.2 }
        });
        this.addChild(titleSprite);

        for (let i = 0; i < 3; ++i) {
            // 選項容器
            const choice = new PIXI.Container();
            choice.y = pixi.stageHeight * 0.6;

            switch (i) {
                case 0:
                    choice.x = pixi.stageWidth * 0.3;
                    break;
                case 1:
                    choice.x = pixi.stageWidth * 0.5;
                    break;
                case 2:
                    choice.x = pixi.stageWidth * 0.7;
                    break;
            }

            // 選項背景
            const choiceBg = choice["bg"] = new PIXI.Sprite({
                texture: textures["choice_bg"],
                anchor: 0.5
            });
            choice.addChild(choiceBg);

            // 選項圖示
            const choiceIcon = choice["icon"] = new PIXI.Sprite({
                texture: textures["icon_magic_bullet"],
                anchor: 0.5,
                y: -choiceBg.texture.height * 0.175
            });
            choice.addChild(choiceIcon);

            this._choices.push(choice);
            this.addChild(choice);

            choice.eventMode = "static";
            choice.cursor = "pointer";
            choice.on("pointertap", this._onChoiceTap, this);
        }
    }

    /**
     * 當小女巫升級時。
     */
    private _onWitchLevelUp(): void {
        // 暫停遊戲
        this._game.pause();

        // 顯示介面
        this.visible = true;

        // 隨機選擇三個升級選項
        const items = ["icon_magic_bullet", "icon_arcane_shield", "icon_attack_damage_up", "icon_speed_boost"];
        items.sort(() => Math.random() - 0.5);
        for (let i = 0; i < this._choices.length; ++i) {
            const choice = this._choices[i];
            const icon = choice["icon"] as PIXI.Sprite;
            const texture = pixi.assets.getSpritesheet("LittleWitch_TheJourney.圖集動畫.uis").textures[items[i]];
            icon.texture = texture;
            choice["type"] = items[i];
        }
    }

    /**
     * 當選項被點擊時。
     */
    private _onChoiceTap(e: PIXI.FederatedPointerEvent): void {

        // 獲取被點擊的選項
        const choice = e.target as PIXI.Container;
        // 獲取被點擊的選項索引
        const index = this._choices.indexOf(choice);

        // ...待補充升級邏輯...

        // 隱藏介面
        this.visible = false;
        // 繼續遊戲
        this._game.resume();

    }

    /**
     * 設置小女巫與 UI 連動。
     * @param witch - 小女巫
     */
    setWitch(witch: Witch): void {
        const oldWitch = this._witch;
        // 如果有舊的小女巫先清除事件監聽
        if (oldWitch) {
            oldWitch.off(Witch.EVENT.LEVEL_UP, this._onWitchLevelUp, this);
        }
        // 設置事件監聽
        this._witch = witch;
        witch.on(Witch.EVENT.LEVEL_UP, this._onWitchLevelUp, this);
    }

}

這本來是今天的重頭戲,但是架構比較龐大,因此我決定把這邊的重點拆到明天去,先把簡單的排版、觸發、關閉流程做好,明天再深入細節,使其選擇後可影響小女巫。

小女巫 遊戲暫停、升級介面 預覽

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

▸ 總結

今天我們成功地為遊戲引入了 Roguelike 遊戲中至關重要的 升級選擇流程遊戲暫停機制

  • 全局控制優化:透過建立 Constants 和修改 GameObject,增強了開發模式下的配置彈性。
  • 核心暫停機制:通過繼承 FPSUpdater 創建了 GameUpdater 類別,統一管理遊戲的更新循環與所有 TWEEN 補間動畫,確保遊戲暫停時畫面能徹底靜止。
  • 經驗值 UI:成功利用昨天的 ProgressBar 實作了 經驗值條 (ExpBar),並透過事件監聽與小女巫的經驗值變化連動。
  • 升級介面架構:完成了 LevelUpLayer基礎排版(半透明背景、標題、三個選項)和 流程控制。當小女巫升級事件觸發時,能夠正確地 暫停遊戲顯示介面隨機選擇選項,並在玩家點擊選項後 繼續遊戲隱藏介面

由於升級系統結構較為龐大,我們決定拆分為兩天。明天,我們將在今天打下的基礎上,專注於完成 升級選擇的實作邏輯,讓小女巫的能力能夠真正得到提升!


上一篇
Day 22:小女巫受傷、無敵幀與血條 UI
系列文
用 PixiJS 寫遊戲!告別繁瑣設定,在 Code.Gamelet 打造你的第一個遊戲23
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言