
Claude Code 寫完《異世界救援》的第一版戰鬥腳本後,我照著實際流程跑了一遍。
出牌、防禦、魔王行動,三種情況都正常。
遊戲也沒有報錯。
乍看之下,這段程式碼好像沒什麼問題。
但我開始多問幾個「如果呢?」之後,才發現裡面其實藏了三個不會主動跳出來的問題。
它們有一個共同點:
程式可以正常執行,畫面也可能看起來正常,但結果不一定是你以為的結果。
這些問題,有些是 AI 已經處理好的,只是我原本不知道它為什麼這樣寫;有些則是我自己故意改壞之後,才發現原本的程式少了一道防線。
所以今天不只是看「AI 寫了什麼」。
我要看的是:
如果我把正常情況改成一個比較奇怪的情況,程式還會不會照預期工作?
《異世界救援》的手牌裡,可能同時出現好幾張完全一樣的卡。
例如:
斬擊
斬擊
斬擊
三張卡的名稱、費用、效果,甚至 id 都一樣。
這時候我想到一個問題:
如果玩家點的是第三張「斬擊」,程式怎麼知道玩家點的是第三張?
原本卡牌點擊後傳出去的是 card_data,也就是這張卡的資料。
所以我做了一個最簡單的測試。
假設手牌裡有三張完全一樣的卡,而玩家實際點的是第 3 張:
var hand: Array = [
{"id": "slash", "name": "斬擊"},
{"id": "slash", "name": "斬擊"},
{"id": "slash", "name": "斬擊"},
]
var clicked_card_data: Dictionary = hand[2]
var found_index: int = -1
for i in hand.size():
if hand[i] == clicked_card_data:
found_index = i
break
結果:
玩家實際點的是 index 2,但用內容比對 find 到的是 index:0
原因其實很簡單。
這三個 Dictionary 的內容完全一樣。
== 比的是內容,不是「這個東西是不是原本那一個」。
程式從第 0 張開始找,第一張就符合,所以直接停下來。
如果真的把這種寫法放進遊戲裡,玩家點第三張斬擊,程式可能會默默把第一張拿走。
沒有錯誤訊息。
因為程式本身完全合法。
只是拿錯了。
這裡的解法不是再去比對更多卡牌資料。
而是把卡牌節點本身一起傳出去。
Claude Code 在 BattleScene.gd 裡使用:
card.card_selected.connect(_on_card_played.bind(card))
接著:
func _on_card_played(card_data: Dictionary, card_node: Card) -> void:
var index: int = card_node.get_index()
這次程式拿到的不只是:
「這張卡的資料是什麼?」
還有:
「玩家點到的那個卡牌節點是哪一個?」
card_node.get_index() 取得的是這個節點目前在手牌列中的位置。
因為我的 UI 是按照 hand 陣列的順序建立卡牌,所以節點順序和陣列索引可以對應起來。
這樣即使三張卡長得一模一樣,程式還是能知道玩家點的是哪一張。
這是我原本讀程式碼時完全沒注意到的地方。
程式看起來只是:
點卡片
↓
取得卡牌資料
↓
出牌
但真的去問:
「如果三張卡完全一樣呢?」
才會發現「卡牌資料」和「卡牌本身」其實是兩件不同的東西。
接下來我又看了一段卡牌效果的程式。
目前所有卡牌效果,都集中在 _apply_card_effect() 裡,用 match 根據 card_id 分派:
func _apply_card_effect(card_data: Dictionary) -> void:
var card_id: String = str(card_data.get("id", ""))
match card_id:
"slash":
_damage_boss(Global.player_attack, card_name)
"heavy_strike":
_damage_boss(Global.player_attack + value, card_name)
# ...其餘卡牌
_:
_add_log("《%s》沒有對應的效果實作。" % card_name)
我注意到最後有一個:
_:
所以我問 AI:
如果新增一張卡,卻忘記在
match裡寫它的效果,會怎樣?
接著我自己做了一個最小測試。
故意使用一個不存在的卡牌 ID:
var energy := 3
var card_id := "new_unimplemented_card"
energy -= 1
match card_id:
"slash":
print("造成傷害")
"parry":
print("獲得護甲")
結果:
match 跑完了,沒有任何輸出、也沒有任何錯誤或例外。
能量已經被扣掉了:2
這就有點麻煩了。
因為 match 找不到符合的分支時,如果沒有 _:,它可以直接跳過。
而我的出牌流程裡,能量是在呼叫 _apply_card_effect() 之前就先扣掉的,卡片也已經從手牌移到棄牌堆。
所以玩家實際上可能遇到:
打出一張新卡
↓
能量 -1
↓
卡片進棄牌堆
↓
沒有任何效果
↓
也沒有錯誤訊息
玩家只會覺得:
「這張卡是不是壞掉了?」
而開發者甚至可能完全不知道。
所以原本那個 _: 分支其實很重要:
_:
_add_log("《%s》沒有對應的效果實作。" % card_name)
它把原本完全沒有聲音的問題,變成一行可以看到的日誌。
這次我才發現,AI 有時候寫的一行看起來不起眼的程式碼,其實是在替未來可能發生的錯誤留一條路。
反過來也是一樣。
如果 AI 沒有寫這道防線,我也不能假設它一定會幫我處理。
第三個坑比較難一點,但也是我這次最有感的一個實驗。
《異世界救援》有一個 Global.gd。
玩家的:
這些資料都需要跨場景保存,所以 Global.gd 被註冊成 Godot 的 Autoload。
簡單來說,它會一直存在,不會因為玩家從寶箱場景切換到戰鬥場景就被刪掉。
而 CardDatabase.gd 則負責保存卡牌的設定。
這兩個東西看起來都可以讓不同地方使用,但它們其實不太一樣:
Global
→ 遊戲進行中的動態資料
CardDatabase
→ 卡牌的固定設定
我當時問 AI:
「為什麼
Global要做 Autoload,但CardDatabase不需要?」
AI 的回答是合理的。
但我還是想知道:
如果兩個東西都做成 Autoload,會發生什麼?
所以我另外開了一個很小的 Godot 專案測試。
測試裡有兩個 Autoload:
[autoload]
Global="*res://scripts/Global.gd"
DB="*res://scripts/DB.gd"
DB 會在自己的 _ready() 裡先建立卡池。
Global 則會在 _ready() 裡向 DB 要資料。
第一次執行:
[Global] _ready 從 DB 取牌組,拿到 0 張
[DB] _ready 填好卡池,共 1 張
結果是:
0 張。
然後我只做一件事情。
把兩行順序對調:
[autoload]
DB="*res://scripts/DB.gd"
Global="*res://scripts/Global.gd"
再跑一次:
[DB] _ready 填好卡池,共 1 張
[Global] _ready 從 DB 取牌組,拿到 1 張
程式碼完全沒有改。
只是 project.godot 裡兩行的位置交換了。
結果就不一樣。
原因是 Autoload 的初始化順序,會受到它們在設定中的登記順序影響。
第一種情況:
Global 先啟動
↓
向 DB 要資料
↓
DB 還沒準備好
↓
拿到空的牌組
第二種:
DB 先啟動
↓
建立卡池
↓
Global 啟動
↓
拿到卡牌
最讓我在意的是:
第一種情況沒有報錯。
沒有錯誤訊息。
退出碼也是:
0
對電腦來說,一切都正常。
只是牌組是空的。
我的遊戲一開始有 4 張牌:
2 張斬擊
2 張招架
如果真的讓兩個互相依賴的 Autoload 發生錯誤的初始化順序,玩家看到的可能不是一個紅色錯誤畫面。
而是:
遊戲正常開啟,但玩家的牌組是空的。
這種問題反而更難找。
因為你看到的是「結果不對」,卻沒有任何錯誤訊息告訴你:
問題發生在 Autoload 初始化順序。
後來 CardDatabase.gd 使用 class_name 加上 static 函式來提供固定的卡牌資料,就不需要再建立一個 Autoload 節點。
也因此,不需要處理這種 _ready() 先後順序。
原本我只知道:
「卡牌資料是固定的,所以不用做 Autoload。」
現在我多了一個更具體的理解:
如果一個全域物件在初始化時需要向另一個全域物件拿資料,就會產生初始化順序的依賴。
而這個順序可能藏在設定檔裡,不一定直接寫在程式碼旁邊。
回頭看這三個問題,它們其實很像。
第一個:
三張卡內容完全一樣
↓
比對內容
↓
找到第一張
第二個:
新增卡片但忘記寫效果
↓
match 沒有符合的分支
↓
什麼都不做
第三個:
Global 先初始化
↓
DB 還沒準備好
↓
拿到空資料
三個都沒有讓程式直接崩潰。
所以如果我只是:
寫完
↓
執行
↓
沒有錯誤
↓
完成
這三個問題都可能被我漏掉。
真正有用的是把正常情況故意換掉:
「如果有三張完全一樣的卡呢?」
「如果新增一張卡,卻忘記寫效果呢?」
「如果兩個 Autoload 的順序反過來呢?」
然後真的跑一次。
坑一,其實是 AI 已經處理好的。
它不是靠我自己找到錯誤,而是我追問之後,才知道為什麼要把 card_node 一起傳進去。
坑二,是我故意拿掉預設分支後,才發現原本的程式少了一道防線。
坑三,則是我不相信一句抽象的「初始化順序很重要」,所以另外開了一個小專案實際測試。
三種情況不太一樣。
但最後都回到同一件事:
不要只問 AI「這樣可以嗎?」
還可以問:
「如果情況變成這樣呢?」
然後真的跑一次。
因為 AI 可以很快寫出一段能執行的程式碼。
但「能執行」和「所有情況都符合預期」,中間還有一段距離。
這段距離,就是我這幾天一直在慢慢補上的東西。
Day 13 又是另一個很有趣的例子。
這次我遇到的是 mouse_filter。
同一個問題,我前後踩了三次:
卡框一次、血條一次、魔王頭像又一次。
明明前面已經踩過,為什麼下一次還是會再踩?
明天來看這個問題。