iT邦幫忙

2025 iThome 鐵人賽

DAY 27
1
Modern Web

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

Day 27:遊戲的收尾工作 - 計時器、結算畫面與音效

  • 分享至 

  • xImage
  •  

昨天,我們完成了 Boss 的實作,今天我們要來添加一些遊戲的收尾工作,讓整個遊戲更加完整,包括計時器、結算畫面以及音效。

Timer:計時器

我打算在遊戲畫面的正上方顯示一個計時器,時間格式為 HH:MM:SS,會隨著遊戲時間的增加而更新,並且會隨著遊戲的暫停與恢復而停止與繼續。

import { Game } from '../Game';

export class Timer extends PIXI.Container {

    // 時間文字顯示
    private _timeText: PIXI.Text;

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

        // 創建時間文字顯示
        const timeText = this._timeText = new PIXI.Text({
            text: "00:00:00",
            style: {
                fontFamily: "Arial",
                fontSize: 20,
                fill: 0xFFFFFF,
                fontWeight: "bold",
                stroke: {
                    color: 0x000000,
                    width: 3
                }
            },
            anchor: 0.5,
            resolution: 2
        } as PIXI.TextOptions);
        this.addChild(timeText);
    }

    /**
     * 更新時間顯示
     */
    update(): void {
        this._updateDisplay(this._game.elapsedTime);
    }

    /**
     * 更新顯示文字
     * @param elapsedTime - 已經過的時間(毫秒)
     */
    private _updateDisplay(elapsedTime: number): void {
        const totalSeconds = Math.floor(elapsedTime / 1000);
        const hours = Math.floor(totalSeconds / 3600);
        const minutes = Math.floor((totalSeconds % 3600) / 60);
        const seconds = totalSeconds % 60;

        const timeString = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
        this._timeText.text = timeString;
    }
}

這是一個相對簡單的計時器,它完全基於 GameelapsedTime 屬性來更新顯示的時間,並且會在 update() 函數中被呼叫。

雖然不是一個很通用的計時器,但由於我們的遊戲目前只需要這樣的功能,因此採用了這種最簡單的實作方式。

TweenUtil:補間動畫小工具

在開始製作結算畫面以前,先來介紹一下我自己在開發專案的時候,基本上一定會自己做的小工具,因為我實在是懶得每一個 Tween 都還要寫一次 newonCompletestart 之類的東西。雖然現在才補可能有點晚了,但還是稍微介紹一下。

/** 補間動畫小工具 */
export class TweenUtil {

  /**
   * 啟動補間動畫,並返回一個在動畫完成時解析的 Promise。
   * @param tween - 補間動畫
   */
  static start(tween: TWEEN.Tween<any>): Promise<void> {
    return new Promise((resolve, reject) => {
      tween["_onUpdateError"] = reject;
      tween.onComplete(resolve).start();
    })
  }

  /**
   * 啟動補間動畫,並返回一個在動畫完成時解析的 Promise。
   * @param obj            - 要補間的物件
   * @param to             - 目標屬性值
   * @param duration       - 補間持續時間
   * @param easingFunction - 補間緩動函數
   */
  static to<T>(obj: T, to: Partial<T>, duration: number, easingFunction = TWEEN.Easing.Linear.None) {
    return this.start(new TWEEN.Tween(obj).to(to, duration).easing(easingFunction));
  }
}

其實 Day 14 就有類似的東西,基本上我就是把 TweenPromise 結合,並且把執行簡化成兩個函數:

  • start():較為通用的執行函數,可以塞入任意 Tween 物件,即可執行並等待結束。
  • to():更快速的執行函數,直接傳入物件屬性動畫時間即可執行,甚至可以帶入 easing 函數,大大簡化了我使用 Tween 的成本。

Promise 結合的好處,就是我們可以隨意地在 async(非同步)函數裡面等待。實際上我自己還會再添加 fromfromTo 之類的函數,但這邊就不弄得那麼複雜了。

GameOverLayer:結算畫面

當小女巫或 Boss 死亡時,我們要暫停遊戲,並且顯示一個結算畫面,告訴玩家遊戲結束了,並且顯示一些統計數據,如總時間、擊敗敵人數量、獲得經驗值等。

先說說結算畫面的設計,讓大家能夠幻想一下:

  • 背景遮罩:與升級畫面類似的黑色半透明背景。
  • 結算視窗:一個有背景圖的視窗,顯示結算資訊與按鈕。
  • 標題:顯示「遊戲結束」或「遊戲勝利」等文字。
  • 統計資訊:顯示遊戲時間、擊敗敵人數量、玩家等級與經驗值等資訊。
  • 按鈕:兩個按鈕,分別是「重新開始」與「返回主頁」。
import { Game } from "../Game";
import pixi = CG.Pixi.pixi;
import { TweenUtil } from './../../Utils/TweenUtil';

/** 結算畫面 */
export class GameOverLayer extends PIXI.Container {

    private _blackMask: PIXI.Graphics;

    private _resultDialog: PIXI.Container;
    private _titleText: PIXI.Text;
    private _subtitleText: PIXI.Text;
    private _timeText: PIXI.Text;
    private _enemiesDefeatedText: PIXI.Text;
    private _playerLevelText: PIXI.Text;
    private _restartButton: PIXI.Container;
    private _mainMenuButton: PIXI.Container;

    private _isAnimating: boolean = false;

    private _game: Game;

    constructor() {
        super();

        // ... (各種初始化顯示物件、排版,暫且省略)

        // 重新開始遊戲按鈕
        const restartButton = this._restartButton = this._createButton("重新開始", () => {
            this._game?.emit(Game.EVENT.RESTART);
            location.reload(); // 暫時先讓網頁重新整理
        });
        restartButton.position.set(-110, 170);
        resultDialog.addChild(restartButton);

        // 返回主選單按鈕
        const mainMenuButton = this._mainMenuButton = this._createButton("返回主頁", () => {
            this._game?.emit(Game.EVENT.BACK);
            location.reload(); // 暫時先讓網頁重新整理
        });
        mainMenuButton.position.set(110, 170);
        resultDialog.addChild(mainMenuButton);

        // 預設隱藏結算畫面
        this.visible = false;
    }

    /**
     * 創建按鈕。
     * @param label   - 按鈕文字
     * @param onClick - 按鈕點擊事件處理函數
     */
    private _createButton(label: string, onClick: () => void): PIXI.Container {

        const button = new PIXI.Container();

        // ... (各種初始化顯示物件、排版,暫且省略)
        
        button.on("pointertap", () => {
            if (this._isAnimating) return;
            onClick();
        });

        const onPointerOver = () => {
            button.scale.set(1.05);
        };
        button.on("pointerover", onPointerOver);
        button.on("pointerup", onPointerOver);

        const onPointerDown = () => {
            button.scale.set(0.98);
        };
        button.on("pointerdown", onPointerDown);

        const onPointerOut = () => {
            button.scale.set(1);
        };
        button.on("pointerout", onPointerOut);
        button.on("pointerupoutside", onPointerOut);

        return button;
    }

    /**
     * 更新遊戲統計資訊
     * @param game - 遊戲實例
     * @param isVictory - 是否為勝利結束
     */
    private _updateGameStats(game: Game, isVictory: boolean = false): void {
        // 更新遊戲時間
        const elapsedSeconds = Math.floor(game.elapsedTime / 1000);
        const hours = Math.floor(elapsedSeconds / 3600);
        const minutes = Math.floor((elapsedSeconds % 3600) / 60);
        const seconds = elapsedSeconds % 60;
        const timeString = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
        this._timeText.text = `遊戲時間:${timeString}`;

        // 更新擊敗敵人數量(暫時設為 0,之後可以從遊戲統計中獲取)
        this._enemiesDefeatedText.text = `擊敗敵人數量:${game.enemiesDefeated}`;

        // 更新玩家等級與經驗值
        const witch = game.witch;
        this._playerLevelText.text = `玩家等級:${witch.level} (經驗值:${witch.exp}/${witch.maxExp})`;

        // 根據遊戲結果更新標題
        this._titleText.text = isVictory ? "遊戲勝利" : "遊戲結束";
        this._subtitleText.text = isVictory ? "成功挑戰 BOSS!" : "再...再一局...";
    }

    async open(game: Game, isVictory: boolean = false): Promise<void> {

        if (this._isAnimating) return; // 防止重複開啟動畫
        this._isAnimating = true;

        this._game = game;

        // 更新結算資訊
        this._updateGameStats(game, isVictory);

        // 播放打開動畫
        this._blackMask.alpha = 0;
        this._resultDialog.alpha = 0;
        this._resultDialog.scale.set(0.6);
        this.visible = true;

        await TweenUtil.to(this._blackMask, { alpha: 1 }, 500, TWEEN.Easing.Cubic.Out);
        await Promise.all([
            TweenUtil.to(this._resultDialog, { alpha: 1 }, 500, TWEEN.Easing.Cubic.Out),
            TweenUtil.to(this._resultDialog.scale, { x: 1, y: 1 }, 500, TWEEN.Easing.Back.Out)
        ]);

        this._isAnimating = false;
    }
}

/**
 * 快速創建文字物件。
 * @param style    - 文字的樣式
 * @param position - 文字物件的位置
 */
function createText(style: Partial<PIXI.TextStyleOptions>, position: PIXI.PointData = { x: 0, y: 0 }): PIXI.Text {
    return new PIXI.Text({
        style, position,
        anchor: 0.5,
        resolution: 2,
    } as PIXI.TextOptions);
}
  • 初始化:在建構子裡面初始化各種顯示物件,並且設置好排版。
  • 按鈕:使用 _createButton() 函數來創建按鈕,並且添加一些預設的互動效果,如縮放、點擊等。
  • 更新統計資訊:使用 _updateGameStats() 函數來更新顯示的遊戲統計資訊。
  • 打開動畫:使用 open() 函數來顯示結算畫面,並且播放淡入與縮放動畫。
  • 快速創建文字物件:使用 createText() 函數來快速創建文字物件,避免重複的程式碼。
  • 按鈕事件:按鈕的點擊事件會觸發 GameRESTARTBACK 事件,目前暫時是讓網頁重新整理,明天會改成更完整的流程。

小女巫 結算視窗 預覽

GameUILayer:整合計時器與結算畫面

有了計時器與結算畫面後,我們需要將它們整合到 GameUILayer 裡面。這邊就不貼初始化的部分了,主要展示它們是如何被使用的。

// GameUILayer.ts

/**
 * 更新 UI 元件
 */
update(): void {
    this._timer.update();
}

/**
 * 顯示遊戲結束畫面
 * @param isVictory - 是否為勝利結束
 */
async showGameOver(isVictory: boolean = false): Promise<void> {
    await this._gameOverLayer.open(this._game, isVictory);
}
  • 計時器更新:在 update() 函數裡面呼叫計時器的 update() 函數來更新顯示的時間。
  • 顯示結算畫面:添加 showGameOver() 函數來顯示結算畫面,Game 裡面會監聽小女巫與 Boss 的 DEATH_END 事件,並且呼叫這個函數來顯示結算畫面。

小女巫 計時器、結算動畫 預覽

SoundManager:音訊管理器

最後,是時候來添加音效了!雖然 pixi.assets.playSound() 就可以播放音效了,但一個遊戲可不能讓音效亂七八糟地播放,因此我們得來實作一個簡單的音訊管理器,來統一管理音效與音樂的播放、音量等。

import pixi = CG.Pixi.pixi;

/** 音訊管理器 */
export class SoundManager {

	private _sfxVolume: number = 1;
	private _musicVolume: number = 0.5;

	private _sfxInstances: PIXI.sound.IMediaInstance[] = [];
	private _currMusic: PIXI.sound.IMediaInstance;

	// 音效音量 (0 ~ 1)
	get sfxVolume(): number { return this._sfxVolume }
	set sfxVolume(value: number) {
		this._sfxVolume = Math.min(Math.max(value, 0), 1);
		this._sfxInstances.forEach(sfx => {
			sfx.volume = (sfx["originalVolume"] ?? 1) * this._sfxVolume;
		});
	}
	// 音樂音量 (0 ~ 1)
	get musicVolume(): number { return this._musicVolume }
	set musicVolume(value: number) {
		this._musicVolume = Math.min(Math.max(value, 0), 1);
		const music = this._currMusic;
		if (music) music.volume = (music["originalVolume"] ?? 1) * this._musicVolume;
	}

	// 當前音樂
	get currMusic(): PIXI.sound.IMediaInstance { return this._currMusic; }

	private _playSfx(alias: string, options?: Partial<{ filename: string; } & PIXI.sound.PlayOptions>): PIXI.sound.IMediaInstance {
		const originalVolume = options?.volume ?? 1;
		options.volume = originalVolume * this._sfxVolume;
		const sfx = pixi.assets.playSound(alias, options);
		sfx["originalVolume"] = originalVolume;
		this._sfxInstances.push(sfx);
		sfx.once("end", () => {
			const index = this._sfxInstances.indexOf(sfx);
			if (index !== -1) this._sfxInstances.splice(index, 1);
		});
		return sfx;
	}

	private _playMusic(alias: string, options?: Partial<{ filename: string; fadeIn: boolean; } & PIXI.sound.PlayOptions>): PIXI.sound.IMediaInstance {
		if (this._currMusic) this.fadeOutMusic();
		const originalVolume = options?.volume ?? 1;
		const volume = originalVolume * this._musicVolume;
		const fadeIn = options?.fadeIn ?? false;
		options = {
			...options,
			volume: fadeIn ? 0 : volume,
			loop: true
		};
		const music = this._currMusic = pixi.assets.playSound(alias, options);
		music["originalVolume"] = originalVolume;
		if (fadeIn) {
			new TWEEN.Tween(music)
				.to({ volume: volume }, 300)
				.start();
		}
		return music;
	}

	/**
	 * 播放音效。
	 * @param filename - 音效檔名
	 * @param volume   - 音效音量 (0 ~ 1)
	 */
	playSfx(filename: string, volume?: number): void {
		this._playSfx("LittleWitch_TheJourney.音效", { volume: volume, filename: filename });
	}

	/**
	 * 播放音樂,會自動淡出當前正在播放的音樂。
	 * @param filename - 音樂檔名
	 * @param volume   - 音樂音量 (0 ~ 1)
	 */
	playMusic(filename: string, volume?: number): void {
		this._playMusic("LittleWitch_TheJourney.音樂.JuhaniJunkala", { volume: volume, filename: filename, fadeIn: true });
	}

	fadeOutMusic(duration: number = 1000): Promise<void> {
		const music = this._currMusic;
		this._currMusic = null;
		return new Promise<void>(resolve => {
			if (!music) resolve();
			else {
				new TWEEN.Tween(music)
					.to({ volume: 0 }, duration)
					.onComplete(() => {
						music.destroy();
						resolve();
					})
					.start();
			}
		});
	}
}

export const soundManager = new SoundManager();
  • 音量控制:可以分別控制音效與音樂的音量,並且會影響到所有正在播放的音效與音樂。
  • 音效播放:使用 _playSfx() 函數來播放音效,並且會記錄正在播放的音效實例,方便後續調整音量或停止。
  • 音樂播放:使用 _playMusic() 函數來播放音樂,並且會自動淡出當前正在播放的音樂,並且支援淡入效果。
  • 淡出音樂:使用 fadeOutMusic() 函數來淡出當前音樂,並且在淡出完成後銷毀音樂實例。
  • 全域物件:由於這個管理器是用於整個專案的,並不是只有遊戲中會使用,主畫面也能夠播放音樂、音效等等,因此直接建立一個全域的 soundManager 物件,讓所有的地方都有一個統一的管理器可以使用。

這是一個簡單,但我認為相當通用的音訊管理器,兩個公用的 playSfx()playMusic() 是針對這個專案添加的。實際上如果把這兩個函數去掉,直接把 _playSfx()_playMusic() 設為公用的話,這個音訊管理器應該就可以用在任何專案了。

你還可以嘗試利用 localStorage 來記錄音量設定,或是添加更多的功能,如靜音、暫停等,但這邊就不多做贅述了。

最後當然就是到處添加音效了,像是小女巫的攻擊、受傷、死亡,敵人的生成、攻擊、受傷、死亡,Boss 的技能等,都可以添加音效來提升遊戲的體驗,這邊就不特別展示了,最後完成的時候我會特別再錄一段影片來展示。

▸ 其他優化

  • 按鍵選擇升級:升級介面 LevelUpLayer 添加了鍵盤的監聽器,除了用滑鼠選擇要升級的選項,也可以用鍵盤上的數字鍵 1、2、3 來進行選擇。這個優化是因為我自己在測試的時候,幾乎都是一隻手放在 WASD 上,另一隻手在發呆,懶得再拿起滑鼠而添加的。

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

▸ 總結

今天我們完成了遊戲的收尾工作,讓整個遊戲更加完整與有趣:

  • 計時器:在遊戲畫面上方顯示經過的時間,並且會隨著遊戲的暫停與恢復而停止與繼續。
  • 結算畫面:當小女巫或 Boss 死亡時,顯示一個結算畫面,告訴玩家遊戲結束了,並且顯示一些統計數據。
  • 音訊管理器:實作了一個簡單的音訊管理器,來統一管理音效與音樂的播放、音量等,並且添加了各種音效來提升遊戲體驗。

到此為止,我們的《小女巫・啟程》遊戲本體已經完成了!明天我會來添加最後的場景切換,讓玩家剛進入遊戲時會看到遊戲封面,之後才會進入遊戲。有餘裕的話,說不定還可以添加開場動畫呢!


上一篇
Day 26:拯救世界的路上怎麼可以少了大魔王?BOSS 登場!
系列文
用 PixiJS 寫遊戲!告別繁瑣設定,在 Code.Gamelet 打造你的第一個遊戲27
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言