iT邦幫忙

2026 iThome 鐵人賽

DAY 20
0
Claude AI

奇幻塔防開發實錄:用 Claude 打造一款有靈魂的塔防遊戲系列 第 20 篇

Day 20:自訂 Workflow 實戰:用 `ultracode` 觸發 Claude Code 除錯一個難纏的碰撞判定 bug

  • 分享至 

  • xImage
  •  

claude_20

系列:奇幻塔防開發實錄:用 Claude 打造一款有靈魂的塔防遊戲
今日工具:自訂 Workflow(/ultracode)/ Claude Code subagents(vue-reviewer、go-reviewer)
今日進度:弩箭穿透疾風掠奪者的 bug 定位並修復,漏擊率 40.8% → 0.3%;/ultracode 從 Day 6 的雛形進化成完整六步驟

前言

先說清楚:/ultracode 不是 Claude Code 的官方功能。它是我放在 .claude/commands/ultracode.md 裡的自訂 slash command,Day 6 建了第一版(六步骨架,但第 3 步是序列驗證,沒有 subagent),今天才長成完整的除錯流程。名字借 ultrathink 的語感:用力想,但要照規矩想。

今天多出的幾條硬規則,來自 Day 12 那週的一次傷口:我在修劇情狀態機的 bug 時,Claude 把測試改成符合錯誤行為,測試綠了、bug 還在。所以第 1 步多了「測試沒寫好之前禁止改實作」,第 3 步要求 subagent 只讀不改,第 6 步的固定格式是給三週後的我看的。規則都是從傷口長出來的——這也是我不把它包成「通用除錯 prompt」分享的原因:它只對這個專案的傷口有效。

要修的 bug 從 Day 17 就記在待辦裡:弩塔的箭常常「擦過」疾風掠奪者;Day 19 在手機上第 4 波(10 隻疾風掠奪者)幾乎打不中。這種「有時候會、換裝置更嚴重」的 bug,正是我需要固定流程的原因——不然很容易改到第三個假設才發現第一個沒驗證。

一、/ultracode 的六步驟

---
description: 標準化除錯流程:重現 → 假設 → 平行驗證 → 最小修復 → 回歸 → 報告
argument-hint: <bug 描述或 issue 連結>
allowed-tools: Read, Grep, Glob, Edit, Write, Bash(go test:*), Bash(bun run test:*), Bash(bun run type-check:*), Bash(git diff:*), Bash(git log:*)
---
你現在進入 **ultracode** 模式。目標:修復以下問題,且不能引入回歸。

問題描述:$ARGUMENTS

嚴格依序執行六步,每一步先輸出結論再往下:
1. **重現**:寫一個「會失敗」的最小測試,跑一次確認它真的失敗。測試沒寫好之前,禁止修改任何實作程式碼。
2. **假設**:列出至少 3 個互斥的根因假設,每個附「若成立,會觀察到什麼」。
3. **平行驗證**:每個假設派一個 subagent(前端 `vue-reviewer`、後端 `go-reviewer`)只讀不改,回報證據、反證、信心分數(0–1),彙整後選出最可能的根因。
4. **最小修復**:只改為修這個根因所必要的行數;先貼 diff 說明再動手。
5. **回歸**:跑 step 1 的測試、該目錄全部測試與型別檢查;涉及機率或效能就貼前後量測。
6. **報告**:固定格式:根因/修法/改動檔案/驗證方式/殘留風險。

畫成圖是這樣,第 3 步是唯一會分岔的一步:

claude_20_diagram_01

Subagent 放在 .claude/agents/,Day 6 只是空殼,今天補上內容。vue-reviewer.md 的 frontmatter 是 name / description / tools: Read, Grep, Glob, Bash——沒有 Edit 也沒有 Write;正文要它扮演資深 Vue 3 工程師、慣例見 frontend/CLAUDE.md,審查重點是引擎純邏輯有沒有被 Pinia Proxy 污染、固定時間步與 dt 邊界、型別安全,並固定回報「證據/反證/信心分數/建議下一步」。

go-reviewer.md 結構相同,審查對象換成 backend/,重點是 Clean Architecture 依賴方向、錯誤包裝與 table-driven tests,再加兩條這個架構特有的:DynamoDB adapter 的 toItem/fromItem 有沒有偷碰 SDK client,以及 main 的雙模式入口有沒有長出 Lambda 專用路由。

二、Step 1:把「有時候」變成數字

/ultracode 三級弩塔打疾風掠奪者(speed 95)時投射物常常穿過去不命中,灰燼步兵正常;手機上更嚴重。相關:CombatSystem.ts

Claude 第一步寫的測試沒有畫面:直線路徑、兩座三級弩塔、200 隻疾風掠奪者,跑完統計發射數與命中數:

// frontend/src/engine/systems/CombatSystem.test.ts(節錄)
const path = new Path([[0, 300], [960, 300]])
const combat = new CombatSystem(new GameEvents(), () => 0.5)
const towers = [new Tower(T.bolt, 0, 480, 200), new Tower(T.bolt, 1, 480, 400)]
for (const t of towers) { t.upgrade(); t.upgrade() }   // 三級:cooldown 0.6、range 140、彈速 900
while (time < 200 * 0.5 + 15) {                        // 每 0.5 秒放一隻
  time += STEP
  spawnDue(); for (const e of list) e.update(STEP, time)  // 順序:spawn → 敵人 → 塔 → 投射物 → cull
  combat.updateTowers(towers, list, STEP)
  combat.updateProjectiles(list, STEP, time)
  list = list.filter((e) => e.alive && !e.reachedEnd)
}
// 回傳 { fired, hit, inFlight, discarded }

測試本體只有兩行斷言:expect(r.inFlight).toBe(0)(沒有殘留在空中的箭)與 expect(r.hit).toBe(r.fired)(每一發都命中);第一次跑:expected 200 to be 338。338 發只中 200 發,漏擊率 40.8%;換成灰燼步兵是 344 發中 343 發。「有時候」變成了可以反覆重現的數字,往下每一步都有東西可以比。

這是第 1 步的形狀:故意比真實需求嚴格的失敗測試;commit 版不長這樣,要等第 5 步。

338 與 344 不是玄學:兩座塔在路徑上下各 100px、range 140,各自覆蓋 |x−480| ≤ 98、196px 寬的窗;疾風掠奪者 95px/s 穿過去給每座塔約 169 次開火機會,灰燼步兵慢、窗開得久就多幾發。固定亂數讓兩次執行的 fired 完全一樣,前後對照才有意義。測試沒有畫面、沒有 requestAnimationFrame,跑完只要 9ms——Day 15 堅持「引擎不認識 Vue」在這裡兌現。

三、Step 2–3:三個假設,三個 subagent

假設 若成立會看到 subagent 回報 信心
H1 直線飛行沒有提前量:箭飛到時敵人已經走掉 漏擊率與敵人速度正相關;慢的敵人幾乎不漏 證據:Projectile 建構子固定 vx/vy;灰燼步兵 0.3% vs 疾風掠奪者 40.8% 0.9
H2 dt 沒有上限:手機掉幀時一步走太遠 掉幀越多漏得越多;桌機不該漏 反證:Game.MAX_DT = 0.1 自 Day 15 就存在,且桌機 60fps 也漏 40.8% 0.1
H3 目標死亡後投射物直接作廢,沒有改追 多塔集火時有「浪費的箭」 證據:if (!p.target.alive) p.alive = false;但影響小(338 發中 4 發) 0.6(次要)

三個 subagent 做的事很不一樣:驗 H1 的把敵人換成灰燼步兵重跑(0.3%),拿到「漏擊率與速度正相關」的證據;驗 H2 的用 grep MAX_DT 找到 Day 15 的 clamp,再翻 git log -S MAX_DT 確認它從沒被拿掉;驗 H3 的數目標中途死亡的次數。回報都附了指令與輸出,我可以逐一重跑——這比「我覺得應該是」可靠得多。

三個 subagent 是平行跑的,總共 6 分鐘。H2 被反證是今天最有價值的一刻:沒有這一步,我原本打算「把 MAX_DT 從 0.1 降到 0.05」——一個對根因毫無幫助的改動。

四、Step 4:最小修復

根因是 H1,但改成「每幀重新朝目標飛」之後會撞到新問題:900px/s 的箭在 1/60 秒走 15px,命中半徑只有幾 px,箭會在敵人身上來回「抖」,掉幀時甚至飛出畫面。所以修復是三件事一起:

// frontend/src/engine/systems/CombatSystem.ts(修復後;對每一發還活著的 p,HIT_RADIUS = 6、RETARGET_RADIUS = 80)
// H3:nearestAlive 就近改追,80px 內沒有活敵人就作廢
if (!p.target.alive && !retarget(p, enemies)) { p.alive = false; this.discarded++; continue }
const dx = p.target.x - p.x, dy = p.target.y - p.y    // H1:每幀朝「現在」的目標飛
const dist = Math.hypot(dx, dy), step = p.speed * dt
if (dist <= step + HIT_RADIUS) { this.hit(p, enemies, now); p.alive = false }  // 這一步走得到就算命中
else { p.x += (dx / dist) * step; p.y += (dy / dist) * step }

Projectile 拿掉 vx/vy,target 改成可重新指派;HIT_RADIUS 從 10 降回 6,因為 swept 之後半徑不再是命中保證,只是視覺容錯。diff 總共 31 行。兩版並排看最清楚:

claude_20_diagram_02

我考慮過另一條路:預測提前量——算出箭飛到時敵人會在哪裡,朝那裡射。數學上可行,但霜語塔與火砲共用同一套投射物,對減速中的敵人會算錯。追蹤是特例最少的解,特例最少的解通常也是 bug 最少的解。

五、Step 5:回歸與前後對照

量測用同一個 runScenario,「手機掉幀剖面」是每 6 幀插入一次 dt = 0.1——節奏來自 Day 19 在 Redmi Note 8 上讀到的掉幀分布。

場景 修復前 修復後
桌機 60fps・疾風掠奪者 40.8%(338 發漏 138) 0.3%(338 發漏 1)
桌機 60fps・灰燼步兵 0.3% 0.6%
手機掉幀剖面・疾風掠奪者 35.3%(317 發漏 112) 0.0%
手機掉幀剖面・灰燼步兵 1.9% 0.0%

修復後剩下的 1–2 發是「目標已死、80px 內沒有敵人」的合理作廢,不該算命中——所以第 1 步那句 hit === fired 修好之後反而永遠不會成立。commit 進 repo 的斷言換成三行:inFlight === 0、hit + discarded === fired、hit / fired >= 0.99。會失敗的測試是拿來定位的,守門的測試是拿來描述正確行為的,本來就不該是同一句。

bun run type-check 零錯誤,12 個測試全過(Day 15 的 4、Day 17 的 5、今天的 3)——包含 Claude 為 H2 加的回歸測試:vi.stubGlobal 假造 requestAnimationFrame,直接呼叫 frame(2000) 模擬分頁切回 2 秒,斷言 step() 只被呼叫 6 次。H2 雖被反證,卻值得一個測試保護未來某個「把 MAX_DT 拿掉看看會不會比較順」的自己。Step 6 的報告直接貼進 PR 描述。

小結

全程 41 分鐘、3 個 subagent、約 6.2 萬 token。真正省下的不是打字時間,而是被流程擋掉的一次錯誤修改。自訂 workflow 的價值是把「先測試、要反證、只改必要的行」變成不需要意志力的預設——這是別人教不了的部分,它得長在你自己的專案習慣上。

一個誠實的補充:如果 Day 17 一開始就寫追蹤式投射物,這個 bug 根本不會存在。但那時我想要「像箭」的直線感,這個美術決定沒經過任何數據檢驗。今天之後,任何影響命中的設計改動都要先過 runScenario。

今日產出

  • [x] .claude/commands/ultracode.md(完整六步驟)、.claude/agents/{vue-reviewer,go-reviewer}.md
  • [x] engine/systems/CombatSystem.test.ts(3 tests:命中、改追、dt 上限)
  • [x] CombatSystem.ts / Projectile.ts 修復(31 行 diff)
  • [x] PR 描述用 Step 6 報告格式

明日預告

Day 21:【週記】Day 15-20 回顧:前後端整合的第一場真實戰役(踩坑實錄)——CORS、off-by-one、無限重開的戰鬥,六個坑一次交代。


上一篇
Day 19:RWD 深水區:讓塔防遊戲在手機瀏覽器也能操作自如的斷點策略
下一篇
Day 21:【週記】Day 15-20 回顧:前後端整合的第一場真實戰役(踩坑實錄)
系列文
奇幻塔防開發實錄:用 Claude 打造一款有靈魂的塔防遊戲 共 21 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言