昨天我們完成了道具系統以及受傷閃白的效果,今天我們要來實作整個遊戲最高潮的部分:Boss!
由於 Boss 肯定會有許多不同於一般敵人的邏輯,因此我打算讓 Boss 繼承 Enemy
變成一個獨立的 BossEnemy
類別,但在這之前需要先針對 BaseCharacter
、Enemy
做一些調整,讓繼承後的 BossEnemy
可以更好的實作其他細節。
這邊我就簡單說明就好,不再展示程式碼了:
BaseCharacter
:
_playDeathAnimation()
函數,death()
改成呼叫此函數來播放動畫,這樣繼承後的子類別就可以自己複寫播放動畫的函數,來演出不同的死亡動畫,但又不會改變死亡的流程。DEATH_END
事件,在播放完死亡動畫且準備 destroy()
前發送,用於未來在小女巫、Boss 死亡時結束遊戲做準備。_createTime
保護屬性,於建構子設為 game.elapsedTime
,讓子類別可以實作一些基於創建時間的動畫演出等。team
屬性與 setTeam()
函數,讓角色可以設定陣營,未來可以用於區分敵我。主要是為了讓敵我都可以發射魔法彈。_onSpawn()
保護函數,於建構子呼叫,讓子類別可以覆寫此函數來實作出生邏輯。ICharacterOptions
介面,讓 _onSpawn()
之前就已經初始化完紋理等屬性,避免子類別覆寫 _onSpawn()
時讀取不到寬高等屬性。Enemy
:
game.addEnemy()
裡設定敵人位置的邏輯移到 _onSpawn()
,因為未來 Boss 生成位置不一樣。另外還有關於 Boss 的 Type 定義,IEnemyConfig
添加了一個 enemyClass
屬性,讓我們可以指定這個敵人要使用哪一個類別來生成,這樣就可以讓 Boss 使用 BossEnemy
類別來生成了。
因為這個調整,Game.addEnemy()
也需要做一些修改,來根據 enemyType.config.enemyClass
來決定要生成哪一個類別的敵人。
由於目前的架構導致
EnemyType
和Enemy
互相引用,似乎出現了一些循環依賴的問題,導致執行時程式找不到Enemy
類別等,我目前還沒搞清楚實際原因,
但我目前的解法是直接在app.ts
裡面事先import EnemyType
,未來有機會在想辦法優化這個部分。
BossEnemy
:建立基本結構import { Game } from './../../Game';
import { Enemy } from "./Enemy";
import { EnemyType } from "./EnemyType";
import pixi = CG.Pixi.pixi;
export class BossEnemy extends Enemy {
// 正在動畫中
private _isAnimating: boolean = true;
/**
* 當敵人生成時。
*/
_onSpawn(): void {
// 將 Boss 設定在畫面右邊外,垂直置中
const halfWidth = this.width * 0.5;
this.position.set(
pixi.stageWidth + halfWidth,
pixi.stageHeight * 0.45
);
this._playEnterAnimation();
}
/**
* 受到傷害。
* @param damage - 傷害數值
*/
takeDamage(damage: number): void {
if (this._isAnimating) return;
super.takeDamage(damage);
}
/**
* 更新循環函數。
* @param dt - 每幀間隔時間(ms)
*/
update(dt: number): void {
// 如果已經死亡則直接結束
if (!this.isAlive) return;
const elapsedTime = this.game.elapsedTime - this._createTime;
// _sprite 微微上下浮動
const timing = elapsedTime % 3000 / 3000;
this._sprite.y = Math.sin(timing * Math.PI * 2) * 5;
}
/**
* 播放進場動畫。
* - Boss 從畫面右邊飛進來,然後停在畫面右側約 90% 的位置。
*/
async _playEnterAnimation(): Promise<void> {
const tween = new TWEEN.Tween(this)
.to({ x: pixi.stageWidth * 0.9 }, 3000)
.easing(TWEEN.Easing.Sinusoidal.Out);
await this.game.startTween(tween);
await this.game.uiLayer.setBoss(this);
this._isAnimating = false
}
/**
* 播放死亡動畫。
* 1. 先稍微往後倒,並左右搖晃
* 2. 然後往下掉落畫面外
*/
async _playDeathAnimation(): Promise<void> {
this._isAnimating = true;
new TWEEN.Tween(this._sprite)
.to({ rotation: Math.PI / 180 * 30 }, 500)
.easing(TWEEN.Easing.Sinusoidal.Out)
.start();
await this.game.startTween(new TWEEN.Tween(this)
.to({ x: this.x + 20 }, 40)
.yoyo(true)
.repeat(8)
.easing(TWEEN.Easing.Sinusoidal.InOut));
await this.game.startTween(new TWEEN.Tween(this)
.to({ y: pixi.stageHeight + this.height }, 2000)
.easing(TWEEN.Easing.Cubic.In));
this._isAnimating = false;
}
}
_onSpawn()
,將 Boss 位置設在畫面右邊外,垂直置中,然後呼叫 _playEnterAnimation()
播放進場動畫。game.uiLayer.setBoss(this)
來顯示 Boss 血條。update()
裡面讓 Boss 的 Sprite 微微上下浮動。_playDeathAnimation()
,讓 Boss 先左右搖晃,然後往下掉落畫面外。uiLayer.setBoss
:為了讓 Boss 有獨立的血條,我在 GameUILayer
裡面添加了 setBoss()
函數,來設置 Boss 血條並且有填滿動畫。BossEnemy
:實作技能系統架構export class BossEnemy extends Enemy {
// ... (其餘省略)
// 正在攻擊中
private _isAttacking: boolean = false;
// 當前的 Tween 動畫
private _currTween: TWEEN.Tween<any>;
// 技能列表(用於定義技能的使用條件、冷卻時間、權重等)
private _skills: Array<{
checkCanUse: () => boolean,
use: () => Promise<void>,
cooldown: number,
lastUsedTime?: number,
weight: number
}> = [];
/**
* 更新循環函數。
* @param dt - 每幀間隔時間(ms)
*/
update(dt: number): void {
// ... (其餘省略)
// 如果正在播放動畫則結束
if (this._isAnimating) return;
// 如果沒有正在攻擊中,則嘗試發動攻擊
if (!this._isAttacking) {
// 篩選出可以使用的技能
const availableSkills = this._skills.filter(skill => {
if (!skill.checkCanUse()) return false;
if (typeof skill.lastUsedTime === "number") {
return (this.game.elapsedTime - skill.lastUsedTime) >= skill.cooldown;
}
return true;
});
if (availableSkills.length > 0) {
// 計算總權重
const totalWeight = availableSkills.reduce((sum, skill) => sum + skill.weight, 0);
// 產生一個 0 到 totalWeight 之間的隨機數
let random = Math.random() * totalWeight;
// 根據權重選擇技能
for (const skill of availableSkills) {
if (random < skill.weight) {
// 使用該技能
skill.lastUsedTime = this.game.elapsedTime;
this._isAttacking = true;
skill.use().then(() => {
this._isAttacking = false;
});
break;
}
random -= skill.weight;
}
}
}
}
/**
* 開始一個 Tween 動畫,並且記錄目前的動畫狀態。如果角色已經死亡,則直接略過。
* @param tween - 要執行的 Tween 動畫
*/
startTween(tween: TWEEN.Tween<any>): Promise<void> {
if (this.isAlive) {
this._currTween = tween;
return this.game.startTween(tween).then(() => {
this._currTween = null;
});
}
return Promise.resolve();
}
/**
* 讓角色死亡。
* @override 覆寫 BaseCharacter 的死亡函數,添加停止動畫
*/
async death(): Promise<void> {
if (!this.isAlive) return;
// 停止目前的動畫
this._currTween?.stop();
this._isAttacking = false;
return super.death();
}
}
_currTween
屬性來記錄目前正在執行的 Tween,並且在 death()
裡面停止它,避免角色死亡後動畫還在跑。_skills
陣列來管理技能,每個技能包含:
checkCanUse
:一個回傳布林值的函數,用於檢查這個技能是否可以使用(例如:血量低於多少、距離目標多遠等條件)。use
:一個回傳 Promise 的函數,當技能被選中時會呼叫它來執行技能邏輯。cooldown
:技能的冷卻時間(毫秒)。lastUsedTime
:上次使用這個技能的時間戳(毫秒),用於計算冷卻。weight
:技能的權重,用於隨機選擇技能時的機率計算。update()
裡面,如果沒有正在攻擊中且沒有播放動畫,則會篩選出可以使用的技能,根據權重隨機選擇一個技能來執行。簡單的說,這就是一個基於條件、冷卻、權重的技能系統架構,接下來我們就可以來實作具體的技能了。
BossEnemy
:定義技能一個會往小女巫的方向,發射三個散射魔法彈的攻擊。
private async _shootMagicBullets(): Promise<void> {
// 讓中間的魔法彈對準女巫
const witch = this.game.witch;
// 發射位置
const pos = { x: this.x - 130, y: this.y - 30 };
const bulletSpeed = 0.3;
const bullets = 3;
const angleStep = Math.PI / 180 * 15; // 每顆魔法彈的角度間隔(度數)
// 面向小女巫的角度
const witchAngle = Math.atan2(witch.y - pos.y, witch.x - pos.x);
for (let i = 0; i < bullets; ++i) {
const angle = witchAngle - (angleStep * (bullets - 1) * 0.5) + (angleStep * i);
const dir = {
x: Math.cos(angle) * bulletSpeed,
y: Math.sin(angle) * bulletSpeed
}
const magicBullet = new MagicBullet(this.game, {
pos: pos,
dir: dir,
scale: 1.3,
damage: 1,
owner: this
});
this.game.effectLayer.addChild(magicBullet);
}
// 讓這個技能等待 1000 毫秒才結束(之後才可以執行下個技能)
await this.game.wait(1000);
}
private async _dashLeft(): Promise<void> {
const witch = this.game.witch;
const hitBox = this.hitBox as PIXI.Rectangle;
// 記錄原本位置
const originalPos = { x: this.x, y: this.y };
const targetY = witch.y;
const targetX = -hitBox.x * 1.5; // 衝撞到畫面左側
// 移動到目標高度
await this.startTween(
new TWEEN.Tween(this)
.to({ x: this.x + 50, y: targetY }, 2000)
.easing(TWEEN.Easing.Sinusoidal.Out)
);
// 衝撞到畫面左側
await this.startTween(
new TWEEN.Tween(this)
.to({ x: targetX }, 1000)
.easing(TWEEN.Easing.Cubic.In)
);
// 搖晃動畫
await this.startTween(
new TWEEN.Tween(this._sprite)
.to({ x: 10 }, 40)
.yoyo(true)
.repeat(6)
.easing(TWEEN.Easing.Sinusoidal.InOut)
);
// 回到原本位置
await this.startTween(
new TWEEN.Tween(this)
.to(originalPos, 2000)
.easing(TWEEN.Easing.Sinusoidal.InOut)
);
}
Tween
:這個技能的邏輯主要是使用 Tween 來移動 Boss 的位置,並且使用上方添加的 startTween()
函數,來確保如果角色死亡,動畫會被停止。因此實際上這是一個通過 Boss 本身的碰撞箱來對小女巫造成傷害的技能。constructor(game: Game, type: EnemyType) {
super(game, type);
this._skills.push({
checkCanUse: () => true,
use: this._shootMagicBullets.bind(this),
cooldown: 2000,
weight: 5
}, {
checkCanUse: () => this.hp / this.maxHp < 0.5 // 生命低於 50% 才會使用
use: this._dashLeft.bind(this),
cooldown: 15000,
weight: 1
});
}
針對每個技能,設定它的使用條件、冷卻時間、權重等。
如此以來,整個 Boss 的就完成了!包含:
Game
的 addEnemy()
添加敵人的物件圖層,讓新加入的敵人會在最下方,避免其他敵人,如 Boss 背後來的小怪擋住。Enemy
、Witch
,讓它們也有浮動效果,讓整個遊戲畫面的質感又更加提升了。_playDeathAnimation()
函數,因此我也讓小女巫有特別的死亡動畫了,她會像是以前的超級瑪利歐一樣,停頓一下,然後一邊逆時針轉圈圈,一邊往畫面下方掉出去。Game
裡面會監聽小女巫與 Boss 的 DEATH_END
事件,兩者任一死亡都會暫停遊戲,為未來顯示結束畫面做準備。startTween()
的時候會直接執行 Tween
,但有可能會在 updater 已經被暫停的時候呼叫,因此改成執行後會判斷是否暫停中,同步停止 Tween
。今天又是一個重要的里程碑,我們完成了這個遊戲最重要的目標之一,成功讓 Boss 登場並且有自己的技能系統。
BossEnemy
類別:繼承自 Enemy,並且覆寫了一些方法來實作 Boss 的特殊行為,如進場動畫、死亡動畫等。雖然我只添加了兩個技能,但這個架構讓我們可以很容易地添加更多的技能,並且根據遊戲需求來調整它們的行為。
到此為止,整個遊戲從開始到結束的主要流程已經完成了,目前我們的《小女巫・啟程》已經有:
明天,我們將進入遊戲的最後階段,開始實作計時器、結算界面以及音效,讓整個遊戲更加完整!