
Day 14 講完 CardDatabase 怎麼把卡牌資料從程式邏輯裡搬出來,變成一份受保護的靜態資料。
今天就來看這些資料怎麼真正被用起來。
《異世界救援》的前 9 關,玩家每次打開寶箱,都會看到 3 張卡牌,選 1 張加入牌組。接著畫面刷新,再出現 3 張新的卡。
一直重複到第 9 關,選完最後一張卡之後,第 10 關就進入魔王戰。
流程看起來很簡單:
進入 RewardScene
↓
抽 3 張卡
↓
玩家選 1 張
↓
加入牌組
↓
關卡 +1
↓
還沒到第 10 關?
↓ ↓
是 否
↓ ↓
重新抽3張 進入魔王戰
但實際把這個流程寫進 Godot 後,我發現「重新抽 3 張卡」並不是把畫面重畫一次這麼簡單。
這一天我攔下來確認了三個細節:
這些問題都不大,卻剛好讓我又多理解了一點 Godot 的場景與事件機制。
RewardScene 進場時,先更新畫面上的狀態,再產生 3 張隨機卡牌。
func _ready() -> void:
_refresh_header()
_generate_rewards()
_refresh_header() 負責顯示目前關卡、玩家 HP、護甲、攻擊力,以及目前牌組張數。
真正產生卡牌的是 _generate_rewards()。
玩家點下一張卡後,Card 會送出 Day 13 提過的 card_selected signal,RewardScene 接到之後,再處理這一連串事情:
func _on_card_selected(card_data: Dictionary) -> void:
if _is_selecting:
return
_is_selecting = true
Global.add_card_to_deck(card_data)
Global.advance_stage()
if Global.is_boss_stage():
_go_to_battle()
else:
_refresh_header()
_generate_rewards()
這裡有一個我在前幾天慢慢建立起來的分工。
Global 負責整場遊戲會一直變動的資料,例如:
RewardScene 不自己保存這些資料,只負責畫面和流程。
所以玩家選卡之後,RewardScene 只是呼叫:
Global.add_card_to_deck(card_data)
Global.advance_stage()
真正的資料還是由 Global 管。
這也剛好接上 Day 12、Day 14 一路整理出來的程式結構。
每次重新抽卡之前,畫面上原本的 3 張卡要先清掉。
Claude Code 寫的是:
func _generate_rewards() -> void:
_is_selecting = false
for child in card_row.get_children():
card_row.remove_child(child)
child.queue_free()
for card_data in CardDatabase.get_random_rewards(REWARD_COUNT):
var card: Card = CARD_SCENE.instantiate()
card.size_flags_vertical = Control.SIZE_SHRINK_CENTER
card_row.add_child(card)
card.setup(card_data)
card.card_selected.connect(_on_card_selected)
我看到這裡時停了一下。
為什麼清掉一張卡,要同時寫:
remove_child()
queue_free()
我第一個直覺是:
queue_free()不就是刪除節點嗎?
那為什麼還需要 remove_child()?
所以我沒有直接接受答案,而是先問 Claude Code,再自己寫一個最小測試。
queue_free() 不是「現在立刻消失」queue_free() 的意思比較接近:
把這個節點排進刪除佇列,稍後再真正刪掉。
也就是說,呼叫:
child.queue_free()
之後,這個節點在當下這一刻仍然存在於場景樹裡。
於是我做了第一組測試:
for child in container.get_children():
child.queue_free()
for i in range(3):
var child := Control.new()
child.name = "new_%d" % i
container.add_child(child)
print(container.get_child_count())
原本有 3 個舊節點。
在同一輪裡先 queue_free(),接著馬上加入 3 個新節點,結果:
換之前子節點數:3
同一輪換完、下一幀處理之前,子節點數:6
- old_0
- old_1
- old_2
- new_0
- new_1
- new_2
這時我才真正看到問題。
queue_free() 雖然已經排程刪除,但在真正刪除之前,舊節點還掛在 container 底下。
如果這時候又加入新卡,就可能出現:
舊卡 3 張
+
新卡 3 張
↓
同一個容器暫時有 6 張
所以我再測第二組:
for child in container2.get_children():
container2.remove_child(child)
child.queue_free()
for i in range(3):
var child := Control.new()
child.name = "new_%d" % i
container2.add_child(child)
print(container2.get_child_count())
這次結果:
同一輪換完,子節點數:3
- new_0
- new_1
- new_2
這兩次實驗讓我比較清楚兩個函式的分工:
remove_child()
↓
立即把節點從父節點移除
queue_free()
↓
排入稍後釋放的佇列
所以這裡不是「同一件事情寫兩次」。
而是兩個不同的動作:
先讓舊卡離開場景樹,再讓 Godot 稍後把它真正釋放掉。
這也是為什麼我最後保留了:
card_row.remove_child(child)
child.queue_free()
這種地方如果只看函式名稱,很容易以為 queue_free() 已經把所有事情都做完了。
實際跑一次,答案就清楚很多。
第二個問題藏在這三行:
if _is_selecting:
return
_is_selecting = true
_is_selecting 是一個很小的布林值。
但它是在防一個很實際的情況:
玩家點下卡牌之後,到下一批卡牌出現之前,這中間有一小段時間。
如果這時候又收到第二次選卡事件,_on_card_selected() 就可能再次執行。
原本的流程是:
點卡
↓
加入牌組
↓
關卡 +1
↓
產生下一批卡
如果同一個操作被觸發兩次,就可能變成:
第一次
↓
加入 1 張卡
↓
關卡 +1
第二次
↓
又加入 1 張卡
↓
又關卡 +1
也就是玩家明明只想選一張卡,遊戲卻可能把同一個事件處理兩次。
所以一收到選卡事件,就先:
_is_selecting = true
接下來如果又進來:
if _is_selecting:
return
直接離開。
等新的 3 張卡產生時,再由 _generate_rewards():
_is_selecting = false
把鎖打開。
整個流程就變成:
正常狀態
_is_selecting = false
↓
玩家選卡
↓
_is_selecting = true
↓
處理選卡
↓
產生新卡
↓
_is_selecting = false
↓
等待下一次選卡
這是一個很小的寫法,卻是在處理一個很常見的問題:
事件已經觸發,但畫面和狀態還沒完全更新完成。
這種「先鎖住,處理完再解鎖」的概念,在其他程式裡也很常見。
在這裡,我只是先用一個 bool 解決我的三選一畫面。
我在做 RewardScene 的時候,BattleScene.tscn 還根本不存在。
因為當時的開發順序是:
Phase 1
先做寶箱三選一
↓
Phase 2
再做戰鬥系統
但從遊戲流程來說,玩家選完第 9 關的卡之後,下一步就是第 10 關魔王戰。
如果程式直接:
get_tree().change_scene_to_file(
"res://scenes/BattleScene.tscn"
)
而檔案又不存在,就會出問題。
所以這裡用了:
func _go_to_battle() -> void:
if not ResourceLoader.exists(BATTLE_SCENE_PATH):
_show_battle_placeholder()
return
get_tree().change_scene_to_file(BATTLE_SCENE_PATH)
先檢查:
ResourceLoader.exists(BATTLE_SCENE_PATH)
如果 BattleScene.tscn 已經存在,就正常切換。
如果還沒有,就先顯示一個暫時畫面。
這樣在 BattleScene 還沒完成的階段,我仍然可以完整測試:
第 1 關
↓
選卡
↓
加入牌組
↓
第 2 關
↓
選卡
↓
...
↓
第 9 關
↓
選卡
↓
第 10 關
↓
暫時畫面
而不是做到第 9 關時,因為下一個場景還不存在,整個流程就中斷。
等之後真的把 BattleScene.tscn 建好,同一段程式就會自然走到另一個分支:
BattleScene 存在?
│
┌──┴──┐
是 否
↓ ↓
進戰鬥 暫時畫面
這對我這種分階段開發的專案滿實用。
還沒完成的部分,可以先留下「如果不存在,就怎麼處理」的路徑。
這樣已經完成的部分,還是能繼續往下測。
做到這裡,我回頭看 RewardScene,原本以為只是:
「抽 3 張卡,讓玩家選 1 張。」
其實至少要同時處理三件不同的事情:
RewardScene
│
┌────────────────┼────────────────┐
↓ ↓ ↓
舊卡清除 防止連點 下一場景
│ │ │
remove_child() _is_selecting exists()
+ │ │
queue_free() ↓ ↓
避免重複 尚未完成也能測
觸發
而這三個問題,都是程式開始跑起來之後,才慢慢冒出來的。
這也是這幾天我越來越習慣的一件事:
AI 把功能寫出來之後,先不急著結案。
我會再找一些「如果呢?」的情況:
如果舊卡還沒真的刪掉呢?
如果玩家點兩次呢?
如果下一個場景根本還不存在呢?
然後把問題縮小,自己跑一次。
RewardScene 最後確實只是一個很小的系統:
抽 3 張 → 選 1 張 → 加入牌組 → 關卡 +1 → 再抽 3 張。
在這個小系統真正跑起來的過程裡,又會多理解了一點 Godot 怎麼管理節點、事件,以及還沒完成的場景。
下一篇,繼續往戰鬥系統走。