iT邦幫忙

2026 iThome 鐵人賽

DAY 26
0
Claude AI

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

Day 26:效能優化總攻:Vue 渲染瓶頸與 Go API 延遲的雙線調校

  • 分享至 

  • xImage
  •  

claude_26

系列:奇幻塔防開發實錄:用 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 第二次。

一、量測方法與兩個 worktree

  • 前端:Day 19 的 ?debug=1 FPS 計數器,第 5 波 10 秒平均。這個參數有閘門:BattleCanvas.vue 只在 import.meta.env.DEV || MODE === 'e2e' 時認它,正式站上打了沒反應。桌機另外套一個沒進版控的臨時補丁 ?stress=150(一次放 150 隻敵人),量完就 revert,所以 repo 裡找不到它。每幀耗時看 Chrome Performance 面板。
  • 後端:那份容器映像是給 Lambda 拉的、不是拿來限 CPU 的,改成本機直接跑同一份 binary 的 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 就值得。

二、前端:三招與一個 bug

Performance 面板說得很清楚:step() 3.8 ms 裡有 2.9 ms 在 pickTarget——每座塔每步掃全部敵人,四座塔 × 150 隻 × 60 次/秒;draw() 6.1 ms 有一半在畫那條永遠不變的路徑。

claude_26_diagram_01

第一招: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 說話,但只說了一半

$ 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 是啟動時載入資料沒錯,但每次請求仍在重新編碼。

claude_26_diagram_02

第一招: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 的次數。

今日產出

  • [x] frontend/src/engine/:SpatialGrid{,.test}.ts、entities/ProjectilePool{,.test}.ts、Renderer.ts 靜態層、systems/CombatSystem.ts
  • [x] backend/:httpapi/cache.go(預先編碼 + ETag)、router.go 的 init 組裝(middleware.Compress 試過又刪)、cmd/api/main.go 只建一次 DynamoDB client
  • [x] 兩個 worktree 的實測數據(第一節)、前後對照表(第四節)

明日預告

Day 27:安全性防禦工事:API 認證、Rate Limit 與雲端資安基本盤補強——現在任何人拿到存檔 UUID 就能改別人的劇情,明天把這扇門關上。


上一篇
Day 25:三個結局的收束:多重劇情分支的測試與 QA 策略
系列文
奇幻塔防開發實錄:用 Claude 打造一款有靈魂的塔防遊戲 共 26 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言