iT邦幫忙

2026 iThome 鐵人賽

DAY 15
0
Vibe Coding

從 Vibe Coding 到系統架構:Gemini x Claude Code 雙 AI 協同開發 Godot 2D Roguelike 卡牌遊戲實戰系列 第 15 篇

Day 15:【事件系統】RewardScene 三選一:9 關構築牌組的最小實作

  • 分享至 

  • xImage
  •  

Day 14 講完 CardDatabase 怎麼把卡牌資料從程式邏輯裡搬出來,變成一份受保護的靜態資料。
今天就來看這些資料怎麼真正被用起來。

《異世界救援》的前 9 關,玩家每次打開寶箱,都會看到 3 張卡牌,選 1 張加入牌組。接著畫面刷新,再出現 3 張新的卡。

一直重複到第 9 關,選完最後一張卡之後,第 10 關就進入魔王戰。
流程看起來很簡單:

進入 RewardScene
      ↓
   抽 3 張卡
      ↓
  玩家選 1 張
      ↓
   加入牌組
      ↓
   關卡 +1
      ↓
 還沒到第 10 關?
   ↓         ↓
   是        否
   ↓         ↓
重新抽3張  進入魔王戰

但實際把這個流程寫進 Godot 後,我發現「重新抽 3 張卡」並不是把畫面重畫一次這麼簡單。

這一天我攔下來確認了三個細節:

  • 舊卡到底要怎麼清掉?
  • 玩家快速點兩次會不會選兩張?
  • 下一個場景還沒做完,現在要怎麼測?

這些問題都不大,卻剛好讓我又多理解了一點 Godot 的場景與事件機制。


一、RewardScene:選一張卡,再產生下一批

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 解決我的三選一畫面。


四、第 10 關還沒做完,RewardScene 要怎麼測?

我在做 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 怎麼管理節點、事件,以及還沒完成的場景。

下一篇,繼續往戰鬥系統走。


上一篇
Day 14:【程式碼理解】同樣是 duplicate(true),保護的東西完全不同?
系列文
從 Vibe Coding 到系統架構:Gemini x Claude Code 雙 AI 協同開發 Godot 2D Roguelike 卡牌遊戲實戰 共 15 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言