
系列:奇幻塔防開發實錄:用 Claude 打造一款有靈魂的塔防遊戲
今日工具:git worktree ×2 / Claude Code ×2 //ultracode(我自訂的 slash command,不是官方功能)/ pprof / Chrome Performance
今日進度:Redmi Note 8 第 5 波 41.3 → 58.9 FPS、桌機 150 敵人壓力測試 31 → 60 FPS;/levels/{id}p99 210 → 38 ms
Day 25 之後遊戲「對」了,但 Day 19 留下一條基準線:手邊最弱的 Redmi Note 8 在第 5 波只有 41.3 FPS,桌機灌 150 隻敵人只剩 31 FPS。後端也有帳要算:Day 24 的日常基準線是 /api/v1/levels/{id} p99 64 ms,但用 hey -c 50 打滿一分鐘,同一條路由的伺服器端 p99 是 210 ms。
Day 14 週記預告過今天:前端 SpatialGrid 與後端 pprof 兩條線的檔案完全不相交,是教科書等級的 worktree 案例。今天是 worktree 第四次上場,也是 /ultracode 第二次。
?debug=1 FPS 計數器,第 5 波 10 秒平均。這個參數有閘門:BattleCanvas.vue 只在 import.meta.env.DEV || MODE === 'e2e' 時認它,正式站上打了沒反應。桌機另外套一個沒進版控的臨時補丁 ?stress=150(一次放 150 隻敵人),量完就 revert,所以 repo 裡找不到它。每幀耗時看 Chrome Performance 面板。net/http 模式,PPROF=1 把 net/http/pprof 掛在 :6060;hey -z 60s -c 50 打 /api/v1/levels/ashfield,延遲取 Day 24 日誌裡的 latency_ms,不含網路。Lambda 上沒有原生 pprof,剖面只能在本機取、驗證在線上做——這是無伺服器少掉的一件工具。Day 15 的固定時間步在這裡第三次回本:step() 的工作量只跟敵人數有關、跟裝置無關,所以桌機上量到的改善會等比例反映到 Redmi,九成的工作可以在桌機做,最後用手機驗收。
兩個 worktree 照 Day 14 的規則開場:A(前端三招,序列估 90 分)在 perf-web,B(後端 pprof,序列估 60 分)在 perf-api,開場 prompt 各自列出會動的檔案、不碰的檔案、另一個 session 在做什麼。
| 指標 | 序列估計 | 平行實測 |
|---|---|---|
| 總 wall-clock | 150 min | 88 min(−41%) |
| 主動切換次數 | 0 | 7 |
| merge 衝突 | 0 | 0 |
| 輸出 token(A / B) | 約 44k | 28.6k / 19.3k |
7 次切換全發生在「等 hey 跑完一分鐘」或「等 vitest」的空檔,形狀跟 Day 13 實驗 2 一樣:任務獨立、有長等待,worktree 就值得。
Performance 面板說得很清楚:step() 3.8 ms 裡有 2.9 ms 在 pickTarget——每座塔每步掃全部敵人,四座塔 × 150 隻 × 60 次/秒;draw() 6.1 ms 有一半在畫那條永遠不變的路徑。

第一招:SpatialGrid。 64 px 的均勻格子(GRID_CELL),每步 rebuild 一次——重填時只把每個 bucket length = 0,不重新配置陣列;塔只查射程涵蓋的那幾格:
// CombatSystem.updateTowers 只改兩行
this.grid.rebuild(enemies) // 每步重建一次
const cands = this.grid.query(t.x, t.y, t.stats.range, this.scratch) // 再餵給 pickTarget
scratch 是重複使用的緩衝,避免每座塔每步配置一個新陣列。query 只是粗篩,精確距離仍由 pickTarget 判斷;SpatialGrid.test.ts 用亂數散佈驗證「格子的結果是暴力法的超集合」——粗篩可以多給,不能少給,少給就是漏目標。
Claude Code 第一個提案是四叉樹。我改成均勻格子,理由有兩個:敵人沿路徑分布、密度不會有極端差異,四叉樹的自適應優勢用不到;而且格子 40 行、四叉樹 150 行,Day 20 之後我對「多出來的 110 行裡藏幾個 bug」很敏感。
第二招:投射物物件池。 一場五波戰鬥 new Projectile 1,912 次,改成 ProjectilePool.acquire/release 之後只 new 了 46 顆。updateProjectiles 的移除也從 filter 換成 swap-remove,整場戰鬥不再產生新陣列。
第三招:靜態層。 背景、路徑、塔位畫進一張離屏 canvas(buildStatic,只在換關時呼叫),每幀只 drawImage 一次。draw() 6.1 → 2.2 ms。
然後 bug 來了:第二波開始,弩箭會從敵人身上「長出來」。這種視覺 bug 最容易被「大概是池子的問題」帶過去,所以我用 /ultracode 走 Day 20 的六步驟。Step 1 的失敗測試(ProjectilePool.test.ts):
const p1 = pool.acquire(towerA, enemy, 900, payload)
p1.x = 500; p1.y = 300 // 飛到一半命中
pool.release(p1)
const p2 = pool.acquire(towerB, enemy, 420, payload) // 重用同一顆
expect([p2.x, p2.y]).toEqual([620, 380]) // 修復前:[500, 300]
| 假設 | subagent 回報 | 信心 |
|---|---|---|
H1 池子重用時 init() 沒重設 x/y,沿用上次命中的位置 |
證據:第一版 init 只設 source/target/payload,座標是舊建構子在設的 |
0.9 |
| H2 靜態層把投射物也畫進去了 | 反證:靜態層在第一發射出前就建好,之後不重畫 | 0.1 |
H3 SpatialGrid 回傳過期的敵人座標 |
反證:格子存的是敵人參照不是座標快照 | 0.1 |
修復是 init() 重設每一個欄位,diff 4 行,全程 24 分鐘。教訓寫進 frontend/CLAUDE.md:物件池的 init 要對每個欄位負責,不能依賴建構子。
$ PPROF=1 make api &
$ go tool pprof -top localhost:6060/debug/pprof/profile?seconds=30
61.3% encoding/json.(*encodeState).reflectValue
11.8% compress/flate.(*compressor).deflate
六成 CPU 在 json.Marshal 走訪 Level——paths 與 waves 的巢狀結構每個請求都用 reflect 重編一次。Day 8 是啟動時載入資料沒錯,但每次請求仍在重新編碼。

第一招:init 階段預先編碼。 httpapi/cache.go 的 newCachedJSON 對一份資料做一次 json.Marshal、取 sha256 前 8 bytes 當 ETag;NewRouter 在組 router 時就把 gamedata 與六關全部算完。請求進來只剩:If-None-Match 相符回 304,否則寫出那段 bytes,附 ETag 與 Cache-Control: public, max-age=300。reflect 只在啟動時發生一次,六關加起來不到 3 毫秒。Claude Code 建議用 sync.Map 做 lazy cache,我改成啟動時全部算完:六關總共 180 KB,沒有 lazy 的必要,少一個並發路徑就少一種 bug。
第二招是個沒有上車的招:壓縮。 perf-api 這個 session 的第二個提案是 middleware.Compress(5, "application/json"),我照做,也真的量到 31 KB → 6.2 KB——pprof 裡那 11.8% 的 compress/flate 就是它的成本。然後我把它整條刪掉:aws-lambda-go-api-proxy 是用 utf8.Valid(body) 決定回應要不要 base64 的,gzip 出來的 bytes 過不了這一關就被標成 base64,而 REST API 沒有設 binary_media_types,那串 base64 會原封不動送到瀏覽器手上——本機 net/http 模式完美運作,上 Lambda 才壞,而且壞得很難查。壓縮的正確位置在邊緣:REST API 自己的 minimum_compression_size = 1024(Day 22 從 CloudFront 搬過來的,順便解掉那個非法的 cache policy),上線後實測 /api/v1/gamedata 1,885 → 663 bytes。那 11.8% 於是變成另一種證據:剖面告訴你成本在哪,不會告訴你這件事該不該在這裡做。
第三招:把昂貴的初始化全部搬進 main。 Day 14 那筆帳今天來收了:dynamo.NewSaveRepo 第一版自己在建構子裡呼叫 config.LoadDefaultConfig,而我又是在請求路徑上建它——於是每一次 invoke 都重跑一次憑證鏈。Logs Insights 看 /story/choose 的 p99 是一條規律的高原,不是尖峰。改法是建構子改收一個已經建好的 client,main 裡 platform.NewDynamoClient(ctx) 只呼叫一次。Lambda 的 main 就是 init 階段,寫在 handler 裡的每一行都會被乘上 invoke 次數。這一招 pprof 看不到——本機模式不打 DynamoDB,只有 Day 24 的結構化日誌配上時間軸才看得出來。Day 14 說「平行開發讓思考的空檔消失」,那天記下的隱憂兩週後準時到期。
| 指標 | 之前 | 之後 | 量測 |
|---|---|---|---|
| Redmi Note 8・第 5 波 FPS | 41.3 | 58.9 | ?debug=1,10 秒平均 |
| 桌機・150 敵人 FPS | 31 | 60(上限) | 同上 |
step() 每幀 |
3.8 ms | 0.9 ms | Performance 面板 |
draw() 每幀 |
6.1 ms | 2.2 ms | 同上 |
一場戰鬥 new Projectile |
1,912 | 46 | pool.created |
/levels/ashfield p50 / p99 |
41 / 210 ms | 9 / 38 ms | hey -c 50,伺服器端 latency_ms |
/levels/ashfield 回應大小 |
31 KB | 6.2 KB(304 時 0) | curl |
/story/choose p99 |
81 ms | 44 ms | 同上 |
每個數字前後各跑三次取中位數,加上來回調參,hey 斷斷續續打了快二十分鐘,Day 24 的 lambda-duration-p99 真的響了——第一次不是演練。它的條件是 p99 連續兩個 5 分鐘窗超過 800 ms,壓測期間剛好滿足。這封信證明那個從 64 ms 往上推 12 倍猜出來的閾值抓得到「壓力測試級」的異常。
兩條線、88 分鐘、零衝突,worktree 第四次驗證了 Day 14 的準則。技術上是老生常談但每次都要重學的三件事:先量測再猜(pprof 直接指出 61% 在哪)、避免每幀配置(池子與 scratch 緩衝)、把不變的東西算一次就好(預先編碼、靜態層)。無伺服器另外加了一條:「一次就好」的那個「一次」,在 Lambda 上是 main,不是 handler——同一份程式碼擺錯地方,成本差的是 invoke 的次數。
frontend/src/engine/:SpatialGrid{,.test}.ts、entities/ProjectilePool{,.test}.ts、Renderer.ts 靜態層、systems/CombatSystem.ts
backend/:httpapi/cache.go(預先編碼 + ETag)、router.go 的 init 組裝(middleware.Compress 試過又刪)、cmd/api/main.go 只建一次 DynamoDB clientDay 27:安全性防禦工事:API 認證、Rate Limit 與雲端資安基本盤補強——現在任何人拿到存檔 UUID 就能改別人的劇情,明天把這扇門關上。