在學習遊戲裡做 Boss 戰:ConjuGate 的關卡設計

ConjuGate 是一個學日文動詞變化的遊戲。玩家一邊閃避敵人一邊回答文法題,答對加分,答錯扣血。這個循環玩 10 分鐘就膩了。每一關的體驗都一樣:敵人出現、答題、過關、下一關繼續。沒有高潮。

我加了 Boss 戰來解決這個問題。每關答對 8 題之後,畫面停止捲動,一隻史萊姆王從畫面頂端降下來。玩家要在 60 秒內答對 6 題把它打倒,答錯會被反擊扣血。打贏拿 500 分和 15 秒時間獎勵,打不贏 Boss 就飛走,遊戲結束。

Boss 戰的節奏設計

Boss 戰跟一般遊玩階段最大的差異是壓力來源不同。一般階段的壓力是空間性的:敵人從上面衝下來,你要一邊閃一邊答題。Boss 戰的壓力是時間性的:畫面不再捲動,你不用閃避,但你有 60 秒的倒數。

這個切換很重要。如果 Boss 戰只是「更難的一般關卡」,玩家感受不到差異。把壓力從空間換成時間,遊戲的節奏會斷開再重新開始,玩家的注意力被重新抓住。

進場

Boss 出現的時候,我做了二件事:

  • 清除畫面上所有敵人和子彈,給玩家一個乾淨的場景
  • 暫停關卡倒數計時器,改用 Boss 專屬的 60 秒計時器

Boss 從畫面外往下滑到 70% 高度的位置,花 1.5 秒。這段時間玩家什麼都不用做,純看動畫。這 1.5 秒的「空白」讓玩家的腦袋從「閃避模式」切換到「專注答題模式」。

戰鬥中

Boss 在畫面上左右搖擺,用 sin 函數控制:

let sway = sin(CGFloat(gameTime) * 1.5) * (scene.size.width * 0.25)
boss.position.x = scene.size.width / 2 + sway

搖擺的幅度是畫面寬度的 25%,速度是 1.5 Hz。這個動畫沒有 gameplay 功能(Boss 的位置不影響答題),但它讓 Boss 有「活著」的感覺,不是一個掛在畫面上的靜態圖片。

答題的部分,每答對一題扣 Boss 1 HP(總共 6 HP),答錯扣玩家 1 顆心。答錯的時候 combo 計數歸零。連續答對 3 題會觸發 stun:Boss 閃爍 2 秒,這段時間可以免費答題不怕答錯(因為 Boss 無法反擊)。

func hit() -> HitResult {
    hp -= 1
    combo += 1

    if hp <= 0 { return .defeated }
    if combo >= GameConfig.bossComboStunThreshold {
        stunTimer = GameConfig.bossStunDuration
        combo = 0
        return .stunned
    }
    return .damaged
}

func miss() {
    combo = 0
}

Stun 機制的設計目的是獎勵連續答對。如果玩家連對 3 題,代表這幾個單字他很熟,stun 給他一個安全窗口繼續快速推進。如果中間答錯了,combo 歸零,壓力回來。

結束

Boss 戰有兩種結局:

  • 打倒:Boss 播放 10 幀的死亡動畫(0.08 秒/幀),然後淡出消失。玩家拿到 500 分和 15 秒時間獎勵。1.5 秒後遊戲結束,顯示結算畫面。
  • 時間到:Boss 往上飛出畫面(1 秒動畫),1.5 秒後遊戲結束。玩家沒有獎勵。

兩種結局都會結束遊戲。我試過另一個版本:Boss 逃走之後關卡繼續,恢復正常的敵人捲動。測試的時候發現打不贏 Boss 之後繼續玩,玩家的情緒是消極的,因為剛才的 60 秒等於白費。直接結束遊戲,讓玩家選擇重來,情緒上比較乾脆。

數值調整的過程

第一版的 Boss 在每關答對 12 題之後出現。測試的時候發現問題:有些關卡比較難,玩家在時間內答不到 12 題就結束了,整場遊戲沒見到 Boss。我把觸發條件從 12 題降到 8 題。

Boss 的 HP 從設計到定案都是 6。我測試了 4 和 8:4 太快,Boss 出場 15 秒就死了,沒有壓迫感。8 在 60 秒內幾乎答不完,壓力太大,而且連續答 8 題文法的過程變得無聊。6 題搭配 60 秒計時器,平均每題有 10 秒思考時間,緊張但不至於來不及。

時間獎勵一開始沒有上限,打贏 Boss 給 15 秒,打 3 次就多了 45 秒。後來我加了限制:每關最多拿 3 次時間獎勵,而且時間不能超過該關的初始時間。避免玩家靠反覆打 Boss 無限續命。

動畫回饋

Boss 有三組動畫:idle(8 幀)、hurt(5 幀)、death(10 幀)。

答對的時候,Boss 停止 idle 動畫,播放 hurt 動畫(5 幀,0.06 秒/幀 = 0.3 秒),播完之後恢復 idle。Stun 的時候 Boss 的 alpha 用 sin 函數在 0.2 到 0.8 之間閃爍。

if stunTimer > 0 {
    stunTimer -= dt
    bossNode?.alpha = 0.5 + CGFloat(sin(stunTimer * 10)) * 0.3
} else {
    bossNode?.alpha = 1.0
}

這些視覺回饋讓玩家的每次答題都有可見的結果。答對看到 Boss 吃痛,答錯看到自己掉血,連對 3 題看到 Boss 閃爍無法動彈。學習遊戲最怕的是玩家感覺「答對跟答錯沒差」,動畫回饋拉大了兩者的差距。

Boss 戰對學習效果的影響

Boss 戰的 6 題會被 FSRS 排程器特別挑選。因為 Boss 戰的答題是有代價的(答錯扣血),玩家會比一般模式更認真思考每一題。FSRS 在 Boss 戰期間記錄的答對/答錯,比一般模式的記錄更能反映玩家的真實掌握程度。

Stun 機制也有學習上的意義:連對 3 題代表這組單字玩家已經熟了,stun 的 2 秒安全窗口讓排程器可以在這段時間出比較難的題目(因為答錯也不會扣血),測試玩家的上限。

把 Boss 戰抽成 BossController

Boss 戰的邏輯一開始寫在 GameScene 裡面,加了 229 行。GameScene 膨脹到 1,700 多行,找 code 變得很痛苦。我把 Boss 相關的所有狀態和邏輯抽成一個獨立的 BossController class(171 行)。

BossController 管理 Boss 的 HP、計時器、combo、stun、動畫、入場和退場。GameScene 只需要呼叫 trigger()hit()miss()update() 這幾個方法。Boss 的整個生命週期都封裝在 BossController 裡面,GameScene 不需要知道 Boss 的動畫怎麼播、HP bar 怎麼畫。

// GameScene 裡面只需要這樣
bossController.trigger(savedTime: run.timeLeft)

// 答對的時候
let result = bossController.hit()
switch result {
case .damaged: SoundManager.shared.play(.hit)
case .stunned: SoundManager.shared.play(.stun)
case .defeated: handleBossDefeated()
}

Boss 戰是一個自成一體的系統:有自己的計時器、自己的 HP、自己的動畫狀態機。把它跟 GameScene 的其他邏輯混在一起,兩邊都會變得更難改。分開之後,我可以獨立調整 Boss 的參數而不用擔心影響到一般的遊玩邏輯。


ConjuGate 是一個用 SpriteKit 寫的 iOS 遊戲,在打怪的過程中學日文動詞變化。這篇是「費曼的實習生」系列,記錄遊戲設計過程中的思考。