經過昨天實作血條 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);
}
}
這本來是今天的重頭戲,但是架構比較龐大,因此我決定把這邊的重點拆到明天去,先把簡單的排版、觸發、關閉流程做好,明天再深入細節,使其選擇後可影響小女巫。
今天我們成功地為遊戲引入了 Roguelike 遊戲中至關重要的 升級選擇流程 和 遊戲暫停機制:
Constants
和修改 GameObject
,增強了開發模式下的配置彈性。FPSUpdater
創建了 GameUpdater
類別,統一管理遊戲的更新循環與所有 TWEEN
補間動畫,確保遊戲暫停時畫面能徹底靜止。ProgressBar
實作了 經驗值條 (ExpBar),並透過事件監聽與小女巫的經驗值變化連動。LevelUpLayer
的 基礎排版(半透明背景、標題、三個選項)和 流程控制。當小女巫升級事件觸發時,能夠正確地 暫停遊戲、顯示介面、隨機選擇選項,並在玩家點擊選項後 繼續遊戲、隱藏介面。由於升級系統結構較為龐大,我們決定拆分為兩天。明天,我們將在今天打下的基礎上,專注於完成 升級選擇的實作邏輯,讓小女巫的能力能夠真正得到提升!