昨天,我們完成了 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;
}
}
這是一個相對簡單的計時器,它完全基於 Game
的 elapsedTime
屬性來更新顯示的時間,並且會在 update()
函數中被呼叫。
雖然不是一個很通用的計時器,但由於我們的遊戲目前只需要這樣的功能,因此採用了這種最簡單的實作方式。
TweenUtil
:補間動畫小工具在開始製作結算畫面以前,先來介紹一下我自己在開發專案的時候,基本上一定會自己做的小工具,因為我實在是懶得每一個 Tween
都還要寫一次 new
、onComplete
、start
之類的東西。雖然現在才補可能有點晚了,但還是稍微介紹一下。
/** 補間動畫小工具 */
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 就有類似的東西,基本上我就是把 Tween
與 Promise
結合,並且把執行簡化成兩個函數:
start()
:較為通用的執行函數,可以塞入任意 Tween
物件,即可執行並等待結束。to()
:更快速的執行函數,直接傳入物件、屬性、動畫時間即可執行,甚至可以帶入 easing 函數,大大簡化了我使用 Tween
的成本。與 Promise
結合的好處,就是我們可以隨意地在 async
(非同步)函數裡面等待。實際上我自己還會再添加 from
、fromTo
之類的函數,但這邊就不弄得那麼複雜了。
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()
函數來快速創建文字物件,避免重複的程式碼。Game
的 RESTART
或 BACK
事件,目前暫時是讓網頁重新整理,明天會改成更完整的流程。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 上,另一隻手在發呆,懶得再拿起滑鼠而添加的。今天我們完成了遊戲的收尾工作,讓整個遊戲更加完整與有趣:
到此為止,我們的《小女巫・啟程》遊戲本體已經完成了!明天我會來添加最後的場景切換,讓玩家剛進入遊戲時會看到遊戲封面,之後才會進入遊戲。有餘裕的話,說不定還可以添加開場動畫呢!