iT邦幫忙

2025 iThome 鐵人賽

DAY 28
1
Modern Web

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

Day 28:遊戲的起點與終點 - 場景管理器與流程切換

  • 分享至 

  • xImage
  •  

昨天,我們完成了遊戲核心的 90% 內容,今天我們要來添加遊戲的起點與終點,讓玩家可以從主頁面進入遊戲,並且在遊戲結束後返回主頁面。

▸ 怎麼管理場景?

在遊戲開發中,場景管理是一個常見的需求,尤其是當遊戲有多個不同的場景(如主頁面、遊戲場景、設定頁面等)時。我們需要一個機制來切換這些場景,並且確保每個場景都有自己的生命週期(如初始化、更新、銷毀等)。

有很多種方式可以實現場景管理,像是使用狀態機、事件系統或是簡單的顯示/隱藏等。由於我們的遊戲並沒有太複雜的場景需要管理,目前預計就只有主頁面以及遊戲場景兩個,因此我打算用一個最簡單的方式來控制我們的場景。

我打算建構一個 BaseScene 類別,讓所有的場景都繼承自這個類別,並且規定好一些基本的功能,如開啟、關閉、動畫等。這樣我們就可以確保每個場景都有一致的行為,並且可以輕鬆地切換場景。

除此之外,每個繼承自 BaseScene 的場景類別,會根據自身的需求來添加對應的回調函數,例如主頁面因為有點擊開始遊戲的需求,因此會有 onStart 回調函數;遊戲場景因為有返回主頁的需求,因此會有 onBack 回調函數。

但是,場景本身並不負責實作這些回調函數的內部細節,他們只負責在對應的時機呼叫這些回調函數,至於回調函數要做什麼事情,將會由我們在 app.ts 裡面來實作。

這樣的設計可以讓場景類別保持簡潔,並且讓我們可以在 app.ts 裡面集中管理場景之間的切換邏輯。

BaseScene:場景基類

接下來,我們先來實作 BaseScene 類別,這個類別會繼承自 PIXI.Container,並且提供基本的開啟與關閉動畫功能。

// Scenes/BaseScene.ts
import { TweenUtil } from "../Utils/TweenUtil";

/** 基本場景類別 */
export class BaseScene extends PIXI.Container {

    private _isAnimating: boolean = false;

    constructor() {
        super();
        // 預設隱藏場景
        this.visible = false;
    }

    /** 是否正在執行開啟或關閉動畫 */
    get isAnimating(): boolean { return this._isAnimating; }

    /** 開啟場景 */
    async open(): Promise<void> {
        if (this._isAnimating) return;
        this._isAnimating = true;
        this.visible = true;
        await this._open();
        this._isAnimating = false;
    }

    /** 開啟場景的具體動畫實現 */
    protected async _open(): Promise<void> {
        this.alpha = 0;
        await TweenUtil.to<PIXI.Container>(this, { alpha: 1 }, 300, TWEEN.Easing.Cubic.Out);
    }

    /** 關閉場景 */
    async close(): Promise<void> {
        if (this._isAnimating) return;
        this._isAnimating = true;
        await this._close();
        this.visible = false;
        this._isAnimating = false;
    }

    /** 關閉場景的具體動畫實現 */
    protected async _close(): Promise<void> {
        await TweenUtil.to<PIXI.Container>(this, { alpha: 0 }, 300, TWEEN.Easing.Cubic.Out);
    }
}
  • 動畫狀態:使用 _isAnimating 屬性來追蹤場景是否正在執行開啟或關閉動畫,避免重複呼叫。
  • 開啟與關閉:提供 open()close() 方法來開啟與關閉場景,並且會呼叫對應的動畫實現方法 _open()_close()
  • 動畫實現:預設的開啟動畫是淡入,關閉動畫是淡出,可以根據需要在子類別中覆寫這些方法來實作不同的動畫效果。

有了最基礎的 BaseScene 之後,接下來就是要分支出 GameSceneMenuScene 了。

GameScene:遊戲場景

遊戲場景會負責管理 Game 的實例,這個工作我們原本是交給 app.ts 裡的 start() 負責,現在我們把他拉進遊戲場景內。

// Scenes/GameScene.ts
import { BaseScene } from "./BaseScene";
import { Game } from './../Games/Game';

/** 遊戲場景 */
export class GameScene extends BaseScene {

    private _game: Game;

    /** 當遊戲按下返回主頁時 */
    onBack: () => void;

    /** 設定並開始遊戲 */
    _setupGame(): void {
        if (this._game) return;

        const game = this._game = new Game();
        this.addChild(game);

        // 監聽遊戲按下返回主頁按鈕時
        game.once(Game.EVENT.BACK, () => {
            if (this.isAnimating) return;
            this.onBack?.();
        });
        // 監聽遊戲按下重新開始按鈕時
        game.once(Game.EVENT.RESTART, () => {
            if (this.isAnimating) return;
            this.clear();
            this._setupGame();
        });

        game.start();
    }

    /** 清除遊戲 */
    clear(): void {
        this._game?.destroy();
        this._game = null;
    }

    protected async _open(): Promise<void> {
        this._setupGame(); // 設置並開始遊戲
        await super._open();
    }

    protected async _close(): Promise<void> {
        await super._close();
        this.clear(); // 清除遊戲
    }
}
  • 遊戲實例:使用 _game 屬性來保存 Game 的實例,並且在 _setupGame() 方法中初始化它。
  • 事件監聽:監聽 GameBACKRESTART 事件,並且呼叫對應的回調函數或重新開始遊戲。
  • 場景開啟:在 _open() 方法中呼叫 _setupGame() 來初始化並開始遊戲。
  • 場景關閉:在 _close() 方法中呼叫 clear() 來銷毀遊戲實例,釋放資源。

MenuScene:主頁面場景

最後是主頁面場景,這個場景會顯示遊戲的封面,並且有一個按鈕讓玩家開始遊戲。

// Scenes/MenuScene.ts
import { BaseScene } from "./BaseScene";
import pixi = CG.Pixi.pixi;
import { TweenUtil } from './../Utils/TweenUtil';
import { soundManager } from './../Utils/SoundManager';

/** 主頁面場景 */
export class MenuScene extends BaseScene {

    private _titleText: PIXI.Text;
    private _tipContainer: PIXI.Container;
    private _tipText: PIXI.Text;

    private _startTime: number;

    constructor() {
        super();

        // ... (其餘初始化背景圖、提示文字等,暫且省略)

        // 創建漸變色
        const gradient = new PIXI.FillGradient({
            type: "linear",
            colorStops: [
                { offset: 0, color: 0xFDF3AE },
                { offset: 1, color: 0xF6B853 },
            ],
        });

        const title = this._titleText = new PIXI.Text({
            text: "小女巫・啟程",
            style: {
                fontFamily: "Source Han Serif TW Heavy",
                fontSize: 60,
                fontWeight: "bold",
                fill: gradient, // 使用漸變色
                stroke: {
                    color: 0x5E1042,
                    width: 6,
                    join: "round",
                } as PIXI.StrokeStyle
            },
            anchor: 0.5,
            position: { x: pixi.stageWidth * 0.3, y: pixi.stageHeight * 0.2 }
        } as PIXI.TextOptions);
        this.addChild(title);

        // 設置點擊事件
        this.eventMode = "static";
        this.on("pointertap", this._onClick, this);
    }

    /** 當點擊開始遊戲時 */
    onStart: () => void;

    /** 當場景被點擊時 */
    private _onClick(): void {
        if (this.isAnimating) return;
        this.onStart?.();
    }

    protected async _open(): Promise<void> {
        this.alpha = 0;
        this._titleText.alpha = 0;
        this._titleText.scale.set(3);
        this._tipContainer.alpha = 0;
        soundManager.playMusic("menu.ogg"); // 播放背景音樂
        await TweenUtil.to<MenuScene>(this, { alpha: 1 }, 500, TWEEN.Easing.Sinusoidal.Out);
        await Promise.all([
            TweenUtil.to(this._titleText, { alpha: 1 }, 1000, TWEEN.Easing.Quintic.In),
            TweenUtil.to(this._titleText.scale, { x: 1, y: 1 }, 1000, TWEEN.Easing.Quintic.In),
        ]);
        await CG.Base2.wait(300);
        this._startTime = Date.now();
        CG.Base2.addUpdateFunction(this, this._update);
        await TweenUtil.to(this._tipContainer, { alpha: 1 }, 500, TWEEN.Easing.Cubic.Out);
    }

    protected async _close(): Promise<void> {
        CG.Base2.removeUpdateFunction(this, this._update);
        soundManager.fadeOutMusic(500); // 淡出背景音樂
        return super._close();
    }

    /** 更新循環函數 */
    private _update(): void {

        // 讓提示文字有縮放動畫
        const elapsedTime = Date.now() - this._startTime;
        const timing = elapsedTime % 2000 / 2000;
        const scale = Math.sin(timing * Math.PI * 2);
        this._tipText.scale.set(1 + scale * 0.02);
    }
}
  • 背景與文字:在建構子裡面初始化背景圖、標題文字、提示文字等,並且設置點擊事件來觸發開始遊戲的回調函數。
  • 漸變色FillGradient 是 PixiJS 用來建立、管理漸層填色的類別,這邊利用它來讓標題文字更有質感。
  • 場景開啟:在 _open() 方法中播放背景音樂,並且播放標題與提示文字的動畫。
  • 場景關閉:在 _close() 方法中停止更新函數,並且淡出背景音樂。
  • 更新函數:使用 _update() 方法來讓提示文字有縮放動畫,增加一些動態效果。

FillGradient 是 PixiJS v8 新增的功能,早期如果你想要讓文字有漸層顏色的話,會直接在 fill 屬性帶入顏色陣列,像是 fill: [0xFDF3AE, 0xF6B853]

小女巫 主頁面 預覽

app.ts:管理場景切換

最後,我們需要在 app.ts 裡面來管理場景之間的切換,這邊就不貼整個 start() 函數了,主要展示場景切換的部分。

// ... (其他 import 程式碼,暫且省略)

async function start() {

	// ... (載入資源、初始化 pixi 等,暫且省略)

	// 主頁面場景
	const menuScene = new MenuScene();
	pixi.root.addChild(menuScene);

	// 遊戲場景
	const gameScene = new GameScene();
	pixi.root.addChild(gameScene);

	// 設置主頁面場景點擊開始遊戲時
	menuScene.onStart = async () => {
		await menuScene.close();
		await gameScene.open();
	};

	// 設置遊戲場景按下返回主頁時
	gameScene.onBack = async () => {
		await gameScene.close();
		await menuScene.open();
	};

	// 開始時先顯示主頁面場景
	menuScene.open();
}

start();
  • 場景實例:在 start() 函數中初始化 MenuSceneGameScene,並且將它們添加到 pixi.root
  • 回調函數:設置 menuScene.onStartgameScene.onBack 回調函數,來切換場景。
  • 初始場景:在遊戲開始時先顯示主頁面場景。

小女巫 場景切換 預覽

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

▸ 總結

今天我們完成了遊戲的起點與終點,讓玩家可以從主頁面進入遊戲,並且在遊戲結束後返回主頁面:

  • 場景管理:設計了一個簡單的場景管理系統,使用 BaseScene 類別來規範場景的行為,並且讓每個場景都能夠輕鬆地切換。
  • 遊戲場景:實作了 GameScene 類別,負責管理 Game 的實例,並且處理遊戲的開始與結束。
  • 主頁面場景:實作了 MenuScene 類別,顯示遊戲的封面,並且提供一個按鈕讓玩家開始遊戲。
  • 場景切換:在 app.ts 裡面管理場景之間的切換,確保玩家能夠順利地從主頁面進入遊戲,並且在遊戲結束後返回主頁面。

到此為止,我們的《小女巫・啟程》遊戲已經完成了主要的功能與流程。原本昨天說有餘裕的話可以加個開場動畫,不過後來想想除了遊戲開發進入尾聲,我們這個 30 天的旅程也要接近尾聲了,因此我就不再拉長整個文章的篇幅了。

明天,我打算來優化遊戲的難度曲線、平衡性,以及進行一些最後的調整與優化,讓整個遊戲更加完善。


上一篇
Day 27:遊戲的收尾工作 - 計時器、結算畫面與音效
下一篇
Day 29:挑戰的藝術 - 難度曲線、遊戲平衡與最終優化
系列文
用 PixiJS 寫遊戲!告別繁瑣設定,在 Code.Gamelet 打造你的第一個遊戲30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言