昨天,我們完成了遊戲核心的 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
之後,接下來就是要分支出 GameScene
、MenuScene
了。
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()
方法中初始化它。Game
的 BACK
與 RESTART
事件,並且呼叫對應的回調函數或重新開始遊戲。_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()
函數中初始化 MenuScene
與 GameScene
,並且將它們添加到 pixi.root
。menuScene.onStart
與 gameScene.onBack
回調函數,來切換場景。今天我們完成了遊戲的起點與終點,讓玩家可以從主頁面進入遊戲,並且在遊戲結束後返回主頁面:
BaseScene
類別來規範場景的行為,並且讓每個場景都能夠輕鬆地切換。GameScene
類別,負責管理 Game
的實例,並且處理遊戲的開始與結束。MenuScene
類別,顯示遊戲的封面,並且提供一個按鈕讓玩家開始遊戲。app.ts
裡面管理場景之間的切換,確保玩家能夠順利地從主頁面進入遊戲,並且在遊戲結束後返回主頁面。到此為止,我們的《小女巫・啟程》遊戲已經完成了主要的功能與流程。原本昨天說有餘裕的話可以加個開場動畫,不過後來想想除了遊戲開發進入尾聲,我們這個 30 天的旅程也要接近尾聲了,因此我就不再拉長整個文章的篇幅了。
明天,我打算來優化遊戲的難度曲線、平衡性,以及進行一些最後的調整與優化,讓整個遊戲更加完善。