iT邦幫忙

2026 iThome 鐵人賽

DAY 22
0

封面示意

Day 03 埋過一個伏筆:規劃階段的傷害公式,和後來實際寫進程式的方式有一點不同。

實作時,Claude Code 另外用了變數記錄「這次擋下了多少」。當時看起來只是寫法上的差異,我沒有特別去追問。

直到 Phase 1 開始補測失敗路徑,戰鬥日誌出現了一個不可能的結果:

魔王攻擊 16,護甲擋下 2,你失去 14 生命。

問題是,那一場戰鬥裡玩家的護甲從頭到尾都是 0。

後來我請 Claude Code 往回追查,才找到原本用來計算「護甲擋下多少」的方式,在血量被 max(0, ...) 夾限時,會把溢殺傷害算成護甲吸收量。


一、原本的寫法,看起來沒有問題

魔王攻擊玩家時,流程很單純:

  1. 先扣護甲。
  2. 護甲不夠,再扣血。
  3. 戰鬥日誌顯示這次擋下多少傷害。

問題是,「護甲擋下多少」這個數字當時並沒有直接保存下來。

當時程式可以直接取得的數字有兩個:

  • 受傷前的血量
  • 受傷後的血量

所以 Claude Code 當時用了這樣的方式:
魔王打出 value 點傷害,玩家實際少了 lost 點生命,那麼兩者的差額,就當成護甲吸收的傷害。

var lost := hp_before - Global.player_hp

var blocked := value - lost

例如魔王打 16 點,玩家掉 11 點血:

16 - 11 = 5

看起來就是護甲擋了 5 點。
這個寫法當時也通過了前面的測試。
貪婪出牌的勝利路徑、第 4 回合打贏,以及純防禦流撐到第 20 回合才輸,都沒有出現異常。
所以當時沒有理由只看這段公式,就直接判定它有問題。


二、專門測失敗路徑後,日誌出現了不可能的數字

Phase 1 補測失敗路徑時,我要求 Claude Code 加入一個很單純的測試:

完全不出牌,一路結束回合,讓玩家承受攻擊直到倒下。

這時候戰鬥日誌出現:

魔王攻擊 16,護甲擋下 2,你失去 14 生命。

但這場測試裡,玩家沒有使用任何防禦卡,護甲值一直都是 0。

也就是:

魔王攻擊:16
護甲:0
實際失血:14

日誌卻算出:

護甲擋下:2

這時我追問 Claude Code:

為什麼這個結果會出現?

因為程式沒有報錯,戰鬥也正常結束,問題只出現在日誌數字上。

接下來需要確認的不是「這段程式碼看起來像不像有問題」,而是這個 2 到底是從哪裡算出來的。


三、根因:max(0, ...) 改變了「實際失血」代表的意思

Claude Code 往回追查後,找到問題出在 Global.take_damage() 裡的這一行:

hp = max(hp, 0)

它的作用是讓玩家 HP 不會低於 0。
這本身沒有問題。

問題在於,前面的 blocked 計算把「實際失血」當成了「這次傷害真正造成的全部傷害」。
這個前提在血量還沒有歸零時成立。
但如果玩家只剩 14 HP,魔王打出 16 點:

原本 HP = 14
受到傷害 = 16

如果沒有夾限,HP 會變成:

14 - 16 = -2

但程式會執行:

hp = max(hp, 0)

最後變成:

HP = 0

我要求 Claude Code 把這個情境單獨重建出來:

var armor: int = 0
var hp: int = 14
var value: int = 16

var hp_before: int = hp
var armor_before: int = armor

if armor >= value:
    armor -= value
else:
    var remaining: int = value - armor
    armor = 0
    hp -= remaining

hp = max(hp, 0)

測試結果:

armor = 0
hp = 0

接著套用原本的公式:

lost = 14 - 0 = 14

blocked = 16 - 14 = 2

於是就得到:

護甲擋下 2

但實際上:

護甲從 0 → 0

根本沒有擋下任何傷害。
這 2 點其實是溢殺傷害。

玩家原本只有 14 HP,卻受到 16 點傷害,多出來的 2 點因為 HP 被限制在 0,沒有出現在「實際失血」裡。
因此用:

傷害 - 實際失血

去反推護甲吸收量時,這 2 點就被算進去了。


四、為什麼前面的測試沒有抓到?

確認這個原因後,我請 Claude Code 再做一組對照測試。

這次設定:

護甲:5
HP:30
魔王攻擊:16

這一擊不會讓 HP 低於 0。

結算後:

armor = 0
hp = 19

原本的公式:

lost = 30 - 19 = 11

blocked = 16 - 11 = 5

實際護甲變化:

armor_before - armor_after
= 5 - 0
= 5

兩種方式得到的結果相同:

原本的公式:5
直接計算護甲變化:5

我把兩組測試結果放在一起比較後,才確認原本的公式並不是每次都算錯。

只要 HP 沒有觸發:

max(hp, 0)

原本的公式就能得到正確答案。

真正有問題的是這個邊界:

受到的傷害 > 剩餘 HP

一旦超過剩餘 HP,部分傷害就不會反映在「實際失血」裡。

這也解釋了為什麼前面的勝利路徑和純防禦測試沒有發現它,而補測「完全不出牌、一路受到傷害直到倒下」時,問題開始穩定出現。


五、修法:要量什麼,就直接量什麼

確認原因後,我請 Claude Code 修改計算方式。

原本:

var hp_before: int = Global.player_hp

Global.take_damage(value)

var lost: int = hp_before - Global.player_hp
var blocked: int = value - lost

改成:

var hp_before: int = Global.player_hp
var armor_before: int = Global.player_armor

Global.take_damage(value)

var lost: int = hp_before - Global.player_hp
var blocked: int = armor_before - Global.player_armor

這樣 blocked 的來源就很直接:

護甲原本是多少
        ↓
受到傷害
        ↓
護甲現在是多少
        ↓
兩者差值 = 實際消耗的護甲

我再要求 Claude Code 用剛才的失敗情境重新驗證:

armor_before = 0
armor_after  = 0

blocked = 0 - 0 = 0

結果就和遊戲狀態一致。

護甲 5 的情境也重新驗證:

armor_before = 5
armor_after  = 0

blocked = 5 - 0 = 5

兩種情境都得到預期結果。

這次的修法並不複雜,關鍵是先確認「這個數字到底應該從哪裡取得」。


六、這個 bug 沒有上線,為什麼還要記下來?

這個 bug 是在提交前的測試階段被抓到並修掉的,玩家實際上沒有看過那句錯誤的戰鬥日誌。
所以這次事件本身沒有造成玩家端的影響。

不過,我會把它留下來,是因為這次問題不是程式直接報錯,而是某個數字在特定條件下失去原本代表的意思。

這類情況如果只測一般流程,不一定會出現。
這次是護甲計算。

如果其他地方也有類似的寫法,而輸出值中間又經過 max、min 或 clamp,同樣的推理就可能在邊界條件下失效。

所以這次留下來的判斷方式比較簡單:

如果要知道某個狀態實際改變了多少,優先直接量那個狀態的前後差值。


七、補測邊界情境

這次能抓到問題,直接原因是 Phase 1 後來補了一條失敗路徑。
如果測試只包含正常戰鬥,這個情況不一定會出現。

所以確認 bug 之後,我也請 Claude Code 補上幾個邊界情境:

HP 還很多,受到傷害
        ↓
正常扣血

HP 剛好等於受到的傷害
        ↓
HP 變成 0

HP 小於受到的傷害
        ↓
觸發 max(0, hp)
        ↓
檢查溢殺是否被錯誤計算

其中最後一種,就是這次真正出問題的情境。
像 max()、min()、clamp() 這類會限制輸出範圍的程式碼,之後測試時就值得特別注意邊界。
因為一般情況下得到正確結果,不代表碰到邊界時,原本的推理仍然成立。


八、這次抓蟲留下來的兩個測試方向

這次的修正本身不複雜,比較重要的是確認問題為什麼只在某些情況出現。
最後留下兩個測試方向。

第一,如果要知道某個狀態改變了多少,優先直接量那個狀態的前後差值。
想知道護甲擋了多少,就比較:

armor_before

和:

armor_after

不要用其他數字的差額去猜。

第二,測試不能只有正常路徑,也要刻意放進邊界情境。

這次的邊界就是:

受到的傷害 > 剩餘 HP

如果沒有這個測試,原本的公式在其他情況下都能得到正確答案,問題就可能繼續留在程式裡。

這個 bug 最後沒有上線,但它讓我多了一個之後檢查程式碼時會注意的條件:
看到「前後差值反推中間值」時,要先確認中間過程有沒有經過 max、min 或 clamp。

Day 23:先量化,再決定要不要改。怎麼用模擬取代「我覺得這張卡有點強」。


上一篇
Day 21:【動畫打磨】讓出牌有「打到了」的手感
下一篇
Day 23:【數值平衡】先量化再動手:用模擬取代「我覺得這張卡有點強」
系列文
從 Vibe Coding 到系統架構:Gemini x Claude Code 雙 AI 協同開發 Godot 2D Roguelike 卡牌遊戲實戰 共 26 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言