iT邦幫忙

2026 iThome 鐵人賽

DAY 10
0

Day 09 的 --check-only 可以檢查 GDScript 有沒有語法錯誤。
但語法正確,不代表程式的邏輯就是對的。
例如一段程式碼完全沒有拼字錯誤,卻把加法寫成減法,--check-only 一樣會通過。

所以今天我再往前一步:
讓 AI 自己跑一場完整的戰鬥,看看遊戲能不能真的跑完。

先寫一支 smoke test

我請 Claude Code 寫一支測試腳本,讓它自己打一場魔王戰。
這種測試通常叫做 smoke test。

這個名字原本來自硬體:新的電路板做好之後,先通電看看會不會冒煙。
軟體裡也是類似的概念。
不需要一次把所有功能都測完,只要先跑一次主要流程,確認它能不能從頭走到尾。

這跟單元測試不太一樣。
單元測試可能是在問:

「這個函式輸入 3,會不會回傳 6?」

smoke test 問的則是:

「這場戰鬥能不能真的打完?」

對還在快速開發的專案來說,這種測試很適合拿來快速確認整體流程。

第一版測試,竟然自己說謊

第一版 smoke test 跑起來之後,出現:

牌組 13 張
結果:失敗(第 9 回合)
魔王 HP 5 / 玩家 HP 30

乍看之下,好像沒什麼問題。
測試確實跑完了,也確實回報了「失敗」。
如果只確認「測試有沒有執行完成」,這裡可能就直接算通過了。

但 Claude Code 沒有直接接受這個結果。
它看了一下輸出的數字,發現:

玩家 HP 30 滿血,魔王 HP 5 也還活著,兩邊都沒有歸零,為什麼戰鬥會結束?

這三個數字確實互相矛盾。
玩家滿血,魔王也還有 5 點血。
而且魔王每回合都會攻擊,已經打到第 9 回合,玩家怎麼可能一滴血都沒有掉?

再跑一次,結果變成:

結果:失敗(第 8 回合)

魔王 HP 10 / 玩家 HP 30

回合數和魔王 HP 不一樣,但有一個數字一直沒變:

玩家永遠是 30。

這代表問題可能不在戰鬥本身,而是測試腳本根本沒有讀到真正的玩家 HP。

這比單純測試失敗更值得注意。
因為測試失敗,至少會讓你去查。
但如果測試看起來正常,裡面的數字卻是假的,就可能帶著錯誤的結果繼續往下開發。

而這一次,是 Claude Code 自己從輸出裡發現了這個矛盾。

追查:畫面上竟然有兩個 Global

這個專案的玩家數值放在 Global.gd,並設定成 Autoload。

Day 09 已經遇過一個問題:
--script 是單檔模式,這種方式執行測試時,我的 Autoload 並沒有正常載入。

所以第一版測試腳本自己建立了一個 Global:

var global = load("res://scripts/Global.gd").new()
global.name = "Global"
root.add_child(global)

看起來好像沒問題。
自己建立一個,命名成 Global,再掛到場景樹裡。

但我加了一些程式碼,去確認這兩個 Global 到底是不是同一個物件。
結果:

我建立的:id=27095205320  player_hp=777
腳本內看到的 Global = @Node@2:<Node#27179091404>  player_hp=30
get_node('/root/Global') = Global:<Node#27095205320>

答案很明顯:
兩個Node編號不一樣,這是兩個不同的物件。

因為 --script 模式只載入單一腳本,沒有載入專案的 project.godot 配置,所以引擎原本該自動生成的單例 (Singleton) 並沒有被建立;手動 new() 出來的只是個普通節點,並不是引擎註冊的全域單例。

我自己建立的那個確實掛在 /root/Global,把它的 HP 改成 777 也真的有改成功。
但場景裡其他程式看到的 Global,卻是另一個物件。
所以測試印出來的:

玩家 HP 30

其實是我自己建立的那一份資料。

整場戰鬥真正使用的,是另一個 Global。
也就是:

測試腳本測的東西,跟遊戲實際在跑的東西,不是同一個。

那直接使用 Global 不就好了?

我又試了一次:

extends SceneTree

func _init() -> void:
    print(Global.player_hp)

結果:

SCRIPT ERROR: Compile Error: Identifier not found: Global

這又回到 Day 09 遇到的問題。

測試腳本本身不能直接使用 Autoload,但場景裡載入的其他腳本卻可以使用。
於是出現了一個很尷尬的情況:

自己建立一個 Global,接不上遊戲真正使用的那個;直接使用 Global,測試腳本又編譯不過。

解法:不要用 --script,把測試當成場景跑

後來我換了一個方向。
既然 --script 的執行方式跟正常遊戲不一樣,那就不要再自己模擬遊戲環境。

我把測試寫成一個正常的 Godot 場景,讓 Godot 照正常的方式啟動。
這樣 Autoload 就會由引擎正常初始化,測試使用的 Global 也會是遊戲真正使用的那一個。

這次發現:
如果測試為了配合工具,開始需要自己手動建立一堆原本由引擎負責的東西,可能代表測試的方法選錯了。

前面自己 new 一個 Global、自己命名、自己掛進場景樹,每一步看起來都合理,但最後還是跟真正的遊戲環境不一樣。

換成場景後,又遇到另一個問題

測試改成場景之後,第一次執行又出現錯誤:

ERROR: Parent node is busy setting up children, `add_child()` failed.

Consider using `add_child.call_deferred(child)` instead.

我的測試場景在 _ready() 裡,想直接把戰鬥場景加到 get_tree().root 底下。

但那個時間點,root 自己還在建立子節點。

所以這時候不能直接 add_child()。

解法很簡單:

func _ready() -> void:
    await get_tree().process_frame

    # 接下來才安全

先讓這一幀跑完,再繼續操作。

這個錯誤訊息也算很直接,甚至連可能的解法都提示了。

不過這種問題屬於執行時才會發生的錯誤,Day 09 的 --check-only 是抓不到的。

還有一個問題:測試腳本會把自己弄死

接著我又遇到一個更容易忽略的問題。
假設測試需要驗證場景切換:

get_tree().change_scene_to_file("res://scenes/BattleScene.tscn")

await get_tree().process_frame

print("切過去了")

看起來很合理。
但 change_scene_to_file() 會把目前的場景釋放掉。

而我的測試腳本自己就是目前的場景。
所以場景切換之後,測試腳本也一起被釋放了。
後面的:

print("切過去了")

根本不會執行。
更麻煩的是,它不一定會出現明顯的錯誤訊息。
程式就停在那裡,看起來很像「卡住了」。
最後我把負責測試的驅動器放到 root 底下:

get_tree().root.add_child(driver)

這樣即使目前的遊戲場景被換掉,測試驅動器還活著,可以繼續執行。

最後的 smoke test

把前面遇到的問題處理掉之後,我的測試大致變成這樣:

extends Node

## 跑完即刪的 smoke test:讓魔王戰自己打一場。
## 當成場景跑(而不是 --script),autoload 才會正常載入。

func _ready() -> void:

    # root 在 _ready() 當下還在建立子節點,這一幀不能對它 add_child()
    await get_tree().process_frame

    for i in 9:
        Global.deck.append(CardDatabase.get_card_by_id("slash"))

    print("牌組 %d 張,玩家 HP %d" % [Global.deck.size(), Global.player_hp])

    var battle = load("res://scenes/BattleScene.tscn").instantiate()
    get_tree().root.add_child(battle)
    await get_tree().process_frame
    var turn := 0

    while not battle.is_battle_over and turn < 30:

        turn += 1

        # 貪婪出牌:能出就出,出到沒能量為止
        var played := true

        while played and not battle.is_battle_over:

            played = false

            for card in battle.hand_row.get_children():

                if not card.disabled:
                    card.card_selected.emit(card.card_data)
                    await get_tree().process_frame
                    played = true
                    break

        if battle.is_battle_over:
            break

        battle.end_turn_button.pressed.emit()
        await get_tree().process_frame

    print("結果:%s(第 %d 回合)" % ["勝利" if battle.boss_hp <= 0 else "失敗", turn])
    print("魔王 HP %d / 玩家 HP %d" % [battle.boss_hp, Global.player_hp])
    get_tree().quit(0 if battle.boss_hp <= 0 else 1)

這次跑出來:

牌組 13 張,玩家 HP 30
結果:失敗(第 5 回合)
魔王 HP 5 / 玩家 HP 0

這次的數字就合理了。
玩家 HP 已經歸零,所以戰鬥結束。
魔王還剩 5 HP,所以結果是失敗。

這裡有兩個我特別留下來的地方

第一個是:

turn < 30

這個上限很重要。
如果遊戲的平衡出了問題,導致戰鬥永遠打不完,測試也不能跟著永遠跑下去。
所以自動化測試需要有一個明確的出口。
第二個是:

get_tree().quit(0 if battle.boss_hp <= 0 else 1)

這裡是我自己指定測試的 exit code:

  • 勝利 → 0
  • 失敗 → 1

這樣測試結果就可以繼續交給其他自動化工具判斷。

這和 Day 09 的 --check-only 不一樣。
Day 09 測到的是 Godot --check-only 在我這次環境下,即使有語法錯誤,exit code 仍然是 0。
這裡則是測試腳本自己呼叫 quit(),明確指定要回傳多少。

為什麼跑完就刪?

這支測試寫完之後,我沒有把它留在專案裡。
乍看之下有點奇怪。
好不容易寫了一支可以自己跑完整場戰鬥的測試,為什麼不用?

原因很簡單:這個階段的專案結構還在一直變。

今天改了卡牌欄位,測試可能要改。
明天改了 Node 路徑,測試又要改。
如果一支測試常常需要跟著專案修改,久了就會變成另一個需要維護的東西。
甚至可能最後變成:

「這支測試現在不能跑,先跳過。」

然後再也沒有人理它。

所以在這個階段,我比較把 smoke test 當成一個驗證工具。
需要確認某個功能時,就請 AI 現寫一支,跑完、看結果,確認沒問題之後刪掉。
等專案結構穩定下來,再把真正值得長期保留的測試留下來。

這是我這次專案採用的做法。

它順便讓我看到一些遊戲問題

這支 smoke test 除了確認程式能不能跑,也讓我順便看到了一些遊戲本身的結果。

貪婪出牌會輸

這次使用的是:

4 張初始牌 + 9 張斬擊 = 13 張牌。

一路看到能出的牌就出,最後第 5 回合倒下,魔王還剩 5 HP。
差一點點。
這對我來說是一個滿有用的數值訊號,至少代表這套牌不是隨便出牌就能輕鬆打贏。

只堆防禦也打不贏

我另外測了一組:

4 張初始牌 + 9 張招架。

這次撐到第 20 回合才倒下,但魔王還剩 10 HP。
也就是說,只增加防禦,還是沒有辦法打贏。
牌組裡要怎麼分配攻擊和防禦,確實會影響結果。

還抓到一個會誤導人的 bug

測試失敗的路徑裡,我還發現一筆奇怪的戰鬥日誌:

魔王攻擊 16,護甲擋下 2

但玩家整場其實都沒有護甲。
這個問題光看程式碼不一定看得出來,因為那段邏輯讀起來很合理。
後來我才一路追到真正的原因。

這個 bug,我會留到 Day 22 再完整拆開。

今天的攔截

Day 01 說過這個系列的心法:攔截 → 追問 → 親手重做

今天我攔下來看的,不只是 AI 寫的程式碼,也包含了測試結果本身。

第一版測試明明回報「失敗」,但玩家 HP 卻一直是 30。
如果只看「測試有沒有跑完」,這個問題很容易被放過。
這次是 Claude Code 自己看到數字矛盾後停下來查。

但我後來也發現,測試能不能抓到問題,還是要看測試本身寫得對不對。
所以到了這一步,我開始把驗證分成幾層:

--check-only
↓
檢查語法

--headless --import
↓
檢查專案能不能正常載入

smoke test
↓
真的跑一次主要流程

godot -e + F5
↓
人實際玩、看畫面

每一層處理的問題都不一樣。

Day 09 解決的是「程式碼有沒有寫錯」。
Day 10 再往前一步,讓 AI 真的跑一次遊戲流程。

而今天最大的收穫,反而是最前面那三行輸出:

結果:失敗
魔王 HP 5 / 玩家 HP 30

測試已經跑完了,但這三行告訴我:
測試本身可能有問題。

所以自動化驗證並不是「AI 跑完就算了」。
AI 可以幫你跑得很快,但輸出的結果,還是要有人確認它合不合理。


上一篇
Day 09:【程式碼品質】讓 AI 自己檢查程式碼,結果發現檢查也要驗證
下一篇
Day 11:【工程配置】同一專案資料夾,如何切出公私雙 Repo?
系列文
從 Vibe Coding 到系統架構:Gemini x Claude Code 雙 AI 協同開發 Godot 2D Roguelike 卡牌遊戲實戰 共 15 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言