
Day 09 的 --check-only 可以檢查 GDScript 有沒有語法錯誤。
但語法正確,不代表程式的邏輯就是對的。
例如一段程式碼完全沒有拼字錯誤,卻把加法寫成減法,--check-only 一樣會通過。
所以今天我再往前一步:
讓 AI 自己跑一場完整的戰鬥,看看遊戲能不能真的跑完。
我請 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)
這樣即使目前的遊戲場景被換掉,測試驅動器還活著,可以繼續執行。
把前面遇到的問題處理掉之後,我的測試大致變成這樣:
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。
也就是說,只增加防禦,還是沒有辦法打贏。
牌組裡要怎麼分配攻擊和防禦,確實會影響結果。
測試失敗的路徑裡,我還發現一筆奇怪的戰鬥日誌:
魔王攻擊 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 可以幫你跑得很快,但輸出的結果,還是要有人確認它合不合理。