模組六|測試、效能與上線(Day 26–30)
昨天講完測試的分工,也講了 CI 只跑四個命令。今天講一張比測試更早存在的東西:效能預算表。
這個專案在寫任何一行遊戲程式之前,PRD.md 就有一節叫「效能預算」,十二列,每列都有目標值與警戒線。它確實反過來決定了架構——Day 14 的節點上限、Day 17 的物件池、Day 15 為什麼不用柔性繩索,答案全都在那張表上。
結論先講:這張表決定了架構,卻沒有決定過任何一次優化——因為它一次都沒被拿去對照過實測。 十二列裡,到 2026-08-07 為止有有效量測值的是零列。這篇要講的就是這件事本身,以及一個更難堪的發現:我後來補的量測工具,第一版說了謊。
PRD.md:2044 的「效能預算」一節,前置語就把它的性質寫清楚了:「以下是專案內部驗收預算,不是框架保證值。」
| 指標 | 目標 | 警戒線 |
|---|---|---|
| 桌機 FPS | 穩定 60 | 低於 55 |
| 中階手機 FPS | 大多數時間 50–60 | 持續低於 45 |
| 單幀 P95 | 小於 20 ms | 超過 25 ms |
| Matter 更新 P95 | 小於 8 ms | 超過 12 ms |
| 蜜蜂同時數 | 16 | 24 硬上限 |
| Matter Body 總數 | 建議低於 100 | 140 硬上限 |
| Constraint 數 | MVP 近乎 0 | 50 硬上限 |
| 防線節點 | 建議 24–48 | 64 硬上限 |
| 初始核心傳輸量 | 壓縮後低於 3 MB | 超過 5 MB |
| 首頁可互動時間 | 中階手機 Wi-Fi 下約 2.5 秒內 | 超過 4 秒 |
| 關卡切換 | 已快取素材時低於 300 ms | 超過 800 ms |
| 一小時 Soak Test | 無持續記憶體成長 | 每次重試明顯增加 |
兩個地方值得單獨講。
第一個是用 P95 不用平均值。平均 16 ms 但每秒卡一次 60 ms,玩起來就是頓;平均值會把這件事藏起來。這條選擇沒有成本,就是寫表格時多打三個字元。
第二個是**「Constraint 數:MVP 近乎 0」那一列,就是 Day 15 的答案**。柔性繩索的做法是每兩個節點之間掛一個 Constraint,一條四十節點的線就是三十九個,直接把這一列打爆。Day 15 那個「為什麼不用繩索」的決定不是寫到那裡才想的——它在寫程式之前就被一張表關掉了,歸檔規格裡那句 MUST NOT be modeled as a flexible constraint chain in the MVP 只是把它抄成驗收條件。
大部分人的順序是「覺得卡了才去查」。這個專案前半段做對了:先訂數字,再讓數字往回長成程式碼裡的常數。
| 預算列 | 決定了什麼 | 落在哪 |
|---|---|---|
| 蜜蜂同時數 16/硬上限 24 | 蜂巢數量與生成間隔(Day 17) | src/config/game.js:63-64 的 BEE_SOFT_LIMIT = 16、BEE_HARD_LIMIT = 24 |
| Body 總數建議 < 100 | 防線節點上限 64(Day 14) | 抽稀與節點上限的參數 |
| Constraint 近乎 0 | 不用柔性繩索(Day 15) | 歸檔規格的一句 MUST NOT |
這是預算表最實在的價值:它把「要多快」翻譯成「所以只能有幾個東西」,而後者可以寫進 config 檔。
實查結果:只有「Matter Body 總數低於 100」那一列有東西在斷言它——tests/e2e/smoke.spec.js:104 與 level-outcome.spec.js:60 各一句 expect(...matterBodyCount).toBeLessThan(100)。
其餘十一列沒有任何工具能驗。
而且這一列的守護還要再打一次折。昨天講過,E2E 不在 CI 的 verify job 裡——deploy.yml 只跑 assets:validate、lint、test:unit、build。所以嚴格按「會讓 CI 失敗」這個定義,十二列有零列被守著;那一列 body 上限的斷言,只有在我自己想起來跑 npm run test:e2e 的時候才會執行。
我要把這件事寫在最前面,因為它會決定你怎麼讀後面的一切:整個 MVP 開發期間,這個專案沒有任何 FPS 計數器、沒有幀時間統計、沒有 debug HUD。
HUD 是 2026-08-07 才補的,?debug=1 才啟用,新增兩個檔:src/core/FrameStats.js(量測)與 src/ui/DebugHud.js(顯示)。它顯示 FPS、單幀耗時的平均與最糟、body 數、蜜蜂數。
兩個設計都是為了「量測工具不要污染被量測的東西」:
Float64Array 裡——這東西每幀記錄一次,而每幀配置一個物件正是效能 HUD 最不該引進的東西。pointer-events: none——HUD 絕不能吃掉一筆畫線。
這是我在這個專案裡最喜歡的一個 bug。
HUD 第一版直接記錄 ticker.deltaMS。開瀏覽器跑起來,「最糟幀間隔」這個數字永遠正好是 100.0ms。不是 99.7,不是 101.3,是每次都剛好 100.0。
任何一個數字剛好卡在整數上不動,它就不是量出來的,是被夾出來的。翻 PixiJS 的原始碼(pixi.js/lib/ticker/Ticker.mjs):
:110 — this._maxElapsedMS = 100
:425-426 — if (elapsedMS > this._maxElapsedMS) { elapsedMS = this._maxElapsedMS }
:497 — get minFPS() { return 1000 / this._maxElapsedMS }
也就是 minFPS 預設 10,等價於「兩幀之間最多算 100 毫秒」。一次 400 毫秒的卡頓,deltaMS 會誠實地告訴你「100 毫秒」。
這個夾取本身是對的——迴圈需要它,否則切回分頁那一瞬間會累積出幾千毫秒要補算,直接死亡螺旋(Day 10 講過)。錯的是我拿一個為了保護模擬而存在的數字,去回答「裝置實際交出了什麼」。
修法是在 tick() 裡自己用 performance.now() 量前後兩幀的起始時間差,跟給模擬用的那個夾取後的值分開:
// src/core/GameLoop.js
const wallClockDeltaMs =
this.lastTickStartedAt === null
? ticker.deltaMS
: startedAt - this.lastTickStartedAt
this.lastTickStartedAt = startedAt
const frameMs = Math.min(ticker.deltaMS, this.maxFrameMs)
this.accumulatorMs += frameMs
// ...fixed-step 迴圈與 updateFrame...
// Record the raw delta, not the clamped `frameMs`: a 400 ms stall must show
// up as a 400 ms stall, otherwise the HUD would report the frame budget we
// pretend to have instead of the one the device delivered.
this.frameStats.record({
deltaMs: wallClockDeltaMs,
durationMs: this.now() - startedAt
})
改完之後,同一組操作量到的最糟幀間隔是 528.7 ms 與 409.9 ms。
還有一層巧合值得標出來:專案自己的 src/config/game.js:5 也寫了 MAX_FRAME_MS = 100。同一個 100 毫秒,是兩道各自獨立、剛好同值的夾取。這種巧合最難查,因為你把自己那道拿掉,數字還是 100。
你以為在量裝置,其實在量框架的預設值。 而且這件事單元測試抓不到——測試餵什麼 delta 進去,它就記什麼;夾取發生在測試邊界之外。是開瀏覽器盯著那個不動的 100.0 才發現的。
補 HUD 的時候撞到一道自己設的閘門。
tests/unit/helpers/readCode.js:8 定義了一個正規表達式 WALL_CLOCK,比對 Date.now 與 performance.now。三個單元測試用它直接把原始碼讀成字串,斷言 BeeSystem、Bee、CountdownSystem、CollisionSystem 這幾個檔案不准出現這兩個 API——遊戲邏輯只能吃固定時間步長,不能吃真實時鐘。
所以幀時間量測不可能寫在模擬層。它只能落在 GameLoop.js(真的負責跟瀏覽器對接的那一層)與 HUD 自己的新檔。
我原本覺得這是麻煩,實際上它把量測推到了正確的位置:唯一有資格量「我們自己的 tick 用掉多少時間」的,就是包住那個 tick 的那一層。 一條為了決定性而設的規則,順手替我做了一個分層決定。
十二列的最後一列是「一小時 Soak Test,無持續記憶體成長」。一小時的實機 soak 我沒做,但可以自動化的那一半做了(tests/unit/leakCycles.test.js):重試 50 次,每次 destroy 之後 Matter world 的 bodies.length 都是 0、殘留 listener 都是 0;切場景 50 次,stage.children 恆為 1。
測試檔的註解把它的邊界寫死了,我直接照抄:
/**
* Leak budget for the retry loop. The tasks list asks for "retry fifty times
* and record the body count"; the honest automated version of that is to assert
* the count comes back to the same place every time rather than trusting a
* single eyeballed number.
*
* These run headless, so they prove the physics/system layer releases what it
* allocates. They do not prove the browser reclaims Pixi textures — that still
* needs a real device, and Day 23 must not claim otherwise.
*/
這是我認為「補測」該有的形狀:把待辦上那句「重試五十次記錄 body 數」翻譯成一個會失敗的斷言,而不是翻譯成一個我自己看過一眼的數字。

現在講最難堪的部分。
HUD 做好之後,我確實跑出過數字:headless Chromium 量到 FPS 5–7、最糟幀間隔 400–530 ms。
這些數字不會出現在這篇文章裡,也不該出現在任何地方。 那是無頭瀏覽器被節流的結果,不是裝置效能。這個專案的量測記錄格式(dev-log.md:26)規定一筆有效紀錄必須同時有裝置型號、瀏覽器版本、蜜蜂數、節點數,缺一項作廢。上面那組全部缺。
所以到 2026-08-07 為止,十二列的實測欄位是空的。零列。
這不是「後來發現不用優化」,是根本沒查過——而遊戲跑得動,所以沒有人想起來要查。
這才是效能預算最常見的失敗模式:不是訂得不好,是訂完就沒再看。 它甚至比「沒訂預算」更有欺騙性,因為那張表看起來很專業,會讓你以為這件事有人在管。
HUD 的程式碼是 AI 寫的,FrameStats、DebugHud、以及它們的兩個單元測試,都是。這部分完全可以委外:驗收條件明確(環形緩衝不准每幀配置、不准掛在模擬層、destroy() 要能拆乾淨),而且測試跑得過就是跑得過。
過程中它撞到過三次牆,三次都是機器擋下來的:把 root = document.body 寫成預設參數,在 environment: 'node' 的測試裡直接 document is not defined(預設參數在 constructor 就求值);節流的初始值設成一整個 interval,導致 mount() 畫完之後第一幀又重畫;還有前面那道 WALL_CLOCK 閘門。
不能交出去的是數字本身。 效能量測的價值在於「在我的目標裝置上實際跑出來的值」——中階 Android 的 GPU 跟桌機差一個數量級,iOS Safari 的記憶體上限更嚴格,背景切回來的第一幀特別長。這些東西不存在於任何模型的權重裡,它們存在於我口袋裡的那支手機裡。
然後我要承認第二件事:我不但沒把它交出去,我自己也還沒做。 上面這段話寫成「所以我親自量了」會好看很多,但那是假的。這一段誠實的版本是:這件事無法委外,而它到現在還沒發生。
效能預算要在寫程式之前訂,因為它會反過來決定架構。但訂完不看,它就只是一份很有說服力的自我安慰。
三件今天就能做的事:
明天 Day 28 講部署。原本我打算寫「GitHub Pages 的 404 地獄」,查證之後發現前提就錯了:這個專案的主要部署是 Vercel(HTTP 200),GitHub Pages 站根本不存在。而且我在一份 build 裡找到了三種不同成因的 404——一種是設定寫錯,一種是 Vite 不碰的那一行,還有一種回 404 給你的根本不是 GitHub。CI 那四個命令三十幾次執行全部通過,站還是不在。
本篇數字的快照時間:2026-08-07 12:35(+0800),對應 commit
5aa3705。專案仍在開發中,量體數字會變動。文中所有效能數字皆標明來源與環境;缺量測條件的數字一律未寫入本文。
可玩網址:https://save-the-dog-web.vercel.app/|原始碼:https://github.com/HarryFan/save-the-dog-web
如果你卡在語法
深入原理