模組三|畫線:從手指到剛體(Day 10–15)
模組二講完了素材那條線:合約、驗證器、人工閘門。從今天開始的六天,講的是玩家滑一下手指,到螢幕上出現一條擋得住蜜蜂的線,中間發生了什麼。
在講手指之前,得先把時間講清楚。因為這個遊戲裡有兩個時鐘,它們跑的速度不一樣,而且不該一樣。
結論先講:畫面用裝置給你的節奏跑,物理用你自己訂的節奏跑,中間夾一個累加器。累加器最危險的地方不是它會累積誤差,是它會在系統變慢的時候把自己拖死。
新手最常見的錯是讓兩套迴圈同時跑:Pixi 的 Ticker 一套,Matter 的 Runner 一套。兩邊各自決定什麼時候前進,結果就沒有人說得準。
這個專案裡沒有第二套。四條指令可以直接查:
| 想確認的事 | 指令 | 結果 |
|---|---|---|
| 有沒有 Matter 的 Runner | grep -rn 'Runner' src/ tests/ |
零命中 |
Engine.update 被呼叫幾次 |
grep -rn 'Engine.update' src/ |
一處:src/core/PhysicsManager.js:28 |
誰呼叫 physicsManager.step() |
grep -rn '\.step(' src/ |
一處:src/scenes/GameScene.js:238 |
| 誰掛上 Pixi Ticker | grep -rn 'ticker.add' src/ |
兩處:src/core/GameLoop.js:48、src/ui/DebugHud.js:73 |
串起來是一條單行道:Pixi Ticker → GameLoop.tick() → sceneManager.updateFixed() → GameScene.updateFixed() → physicsManager.step() → Engine.update()。整個物理世界只有這一個入口。
兩個看起來像例外的東西,都不是迴圈。DebugHud(src/ui/DebugHud.js:73)掛 Ticker 只是為了重畫一塊 DOM 疊層,?debug=1 才會存在。src/main.js:54 那個 requestAnimationFrame 是把場景切換延後一幀,避免在 Pixi 還在派送事件的時候把場景銷毀掉——它一輩子只跑一次。
GameScene 對外只暴露兩個更新方法,差別寫在名字裡:
updateFixed(deltaMs) |
updateFrame() |
|
|---|---|---|
| 誰決定它跑幾次 | 累加器 | 裝置的刷新率 |
| 一幀可能跑幾次 | 0 到 3 次 | 恰好一次 |
| 收到的時間 | 永遠是 1000/60 毫秒 |
沒有參數 |
| 裡面做什麼 | 物理、防線阻尼、碰撞判定、蜜蜂 AI、倒數 | 把剛體座標寫回 Pixi、更新貼圖與文字 |
有個細節值得停一下:GameLoop 呼叫 updateFrame(frameMs) 時是有傳參數的(src/core/GameLoop.js:97),但 GameScene.updateFrame() 的簽章是空的(:245)。渲染那層不需要知道這一幀多長——它只是把物理算完的位置抄過去。這個「傳了但沒接」看起來像疏漏,實際上剛好標示出兩層的界線在哪裡。
updateFixed 裡面的順序有註解護著(src/scenes/GameScene.js:235-237):物理與它引發的碰撞事件先跑,然後是出界判定,最後才是倒數。這樣一來,這一步裡發生的失敗一定比倒數結束早一步抵達狀態機。
固定步長 1000/60 毫秒、單幀上限 100 毫秒、追趕上限 3 次,三個值都在 src/config/game.js:4-6。用它們的地方長這樣(src/core/GameLoop.js:79-97):
const frameMs = Math.min(ticker.deltaMS, this.maxFrameMs)
this.accumulatorMs += frameMs
let fixedSteps = 0
while (
this.accumulatorMs >= this.fixedStepMs &&
fixedSteps < this.maxCatchUpSteps
) {
this.sceneManager.updateFixed(this.fixedStepMs)
this.accumulatorMs -= this.fixedStepMs
fixedSteps += 1
}
if (fixedSteps === this.maxCatchUpSteps) {
this.accumulatorMs = Math.min(this.accumulatorMs, this.fixedStepMs)
}
this.sceneManager.updateFrame(frameMs)
這一幀過了多久,先夾一次上限,加進累加器。只要存款夠一個步長就跑一次物理、扣一個步長,跑到不夠為止。跑完,不管跑了幾次,畫面都更新一次。
while 的第二個條件是這段程式碼裡最重要的東西。沒有它,這一行會在某些情況下跑幾十次、幾百次。

死亡螺旋的形狀是這樣:某一幀特別慢(分頁切到背景再切回來、系統去做別的事),累加值一次進來很大,這一幀要補跑幾十次物理,於是這一幀更慢,於是下一幀累積更多——雪球滾下去,整個頁面卡住。
兩道閘門各擋一半:
| 閘門 | 值 | 位置 | 擋的是什麼 |
|---|---|---|---|
| 單幀上限 | 100 毫秒 | src/config/game.js:5,套用在 GameLoop.js:79 |
一次進帳的上限 |
| 追趕上限 | 3 次 | src/config/game.js:6,守衛在 GameLoop.js:86 |
一幀最多花多少時間補跑 |
單元測試把行為釘死了(tests/unit/GameLoop.test.js:23,limits catch-up fixed updates after a long frame):丟一個 250 毫秒的幀進去,updateFixed 只被呼叫三次,updateFrame 收到的是被夾過的 100,而累加器最後停在 10。
停在 10 而不是 0,是這裡第二個值得看的設計。撞到追趕上限的時候,程式碼不是把累加器清空,而是夾到「一個步長」(GameLoop.js:93-95)。清空等於承認丟掉了那段時間,夾住等於保留一步的餘量讓下一幀繼續追。為什麼選夾不選清,程式碼裡沒有註解,PRD 裡也沒有寫——我只能說行為是這樣,理由是我事後回推的,這是推論。
還有一件必須誠實講的事:我沒有任何紀錄顯示這個專案真的卡死過。 這兩道閘門是預防性的,不是修 bug 修出來的。PRD 自己就是這樣寫的(PRD.md:717):「這是本專案的穩定度策略,不是 Matter.js 的強制規則。」我不打算把一段沒發生過的災難寫成親身經歷。
寫這篇的時候我去翻了 Pixi 的原始碼,發現一件事:Ticker 自己就會夾。它的 _maxElapsedMS 預設值是 100,超過就壓平【實測:node_modules/pixi.js/lib/ticker/Ticker.mjs:110 與 :425-426,PixiJS 8.19.0】。
也就是說,在預設設定下,Math.min(ticker.deltaMS, 100) 這道保險永遠不會作動——送進來的值已經被 Pixi 壓到 100 以下了。它不是沒有用(如果有人把 minFPS 調高,Pixi 的夾點就會上移,這時我們自己那道才會接手),但它確實不是這個專案發明的防線。
這件事在別的地方咬過人。GameLoop 為了量測,特地不信任 ticker.deltaMS(src/core/GameLoop.js:67-77):
// Measure the gap ourselves instead of trusting `ticker.deltaMS`. Pixi
// clamps that value at its own `minFPS` (100 ms by default), so a 400 ms
// stall arrives here already flattened to 100 ms and the HUD would report a
// dropped frame as a merely slow one. The first tick has no predecessor, so
// it falls back to the ticker's value.
const wallClockDeltaMs =
this.lastTickStartedAt === null
? ticker.deltaMS
: startedAt - this.lastTickStartedAt
this.lastTickStartedAt = startedAt
模擬要用被夾過的值(不然就會死亡螺旋),量測要用沒被夾過的值(不然 400 毫秒的掉幀會被記成 100 毫秒的慢幀)。同一個數字,兩個用途,取捨相反。這個區分有測試守著(tests/unit/GameLoop.test.js:80,measures the real gap between frames rather than trusting the ticker)。
物理算完之後,要把結果搬到畫面上。整個橋只有這樣(src/systems/RenderSyncSystem.js:32-42):
sync() {
for (const entry of this.entries) {
const { body, displayObject, offsetX, offsetY, syncAngle } = entry
displayObject.position.set(body.position.x + offsetX, body.position.y + offsetY)
if (syncAngle) {
displayObject.rotation = body.angle
}
}
}
單向、無條件、直接複製。類別的註解寫明了規矩:這裡任何東西都不准回頭去讀物理世界,否則模擬就會依賴幀率。
這裡要糾正一件我自己寫的規格。PRD.md:704-708 的 GameLoop 骨架裡算了一個 interpolationAlpha,並且傳給 renderSyncSystem.sync(interpolationAlpha)。實作的 sync() 沒有參數,全 src/ 找不到任何插值。 規格寫了,實作沒做,而且沒有任何地方記錄這個決定是什麼時候發生的。我不編一個當初的評估,就是沒做。

這十一行害過人。a20346c(2026-08-06 16:09:44)是這個模組唯一一個直接打在渲染同步上的 fix,commit message 第一句是:
兩個渲染 bug,267 個單元測試與 75 個 E2E 測試全都沒抓到,因為每一條斷言檢查的都是 debug 狀態或剛體座標,沒有一條真的看過畫面上的像素。
(那兩個測試數字是當時的,跟本文文末的快照不同期。)
第一個 bug:鎖定後的線被畫在玩家畫的座標上,然後就再也不動了。剛體照樣在掉、在翻、被蜂群推——圖跟模擬分家,蜜蜂直接穿過畫面上那條線。修法是把 Graphics 改成繞著剛體質心畫,再註冊給 RenderSyncSystem 帶著走(src/systems/DrawingSystem.js:296-314、src/scenes/GameScene.js:163)。
第二個 bug:Graphics.stroke() 會消耗路徑,所以第二道 stroke 什麼都沒畫,線失去了亮色的核心,只剩深色外框。修法是每一道 pass 都重建一次路徑(src/systems/DrawingSystem.js:316-342)。
「渲染跟模擬是兩件事」這句話的另一半是:它們必須被明確地接回去。 沒接回去的時候,程式不會報錯,測試也不會紅,因為斷言都在看 body 座標,而 body 座標一直都是對的。
模擬層可以。tests/unit/determinism.test.js:74 證明同種子同輸入會逐位重播,:89 進一步證明在 120、300、900 步之下也一樣。固定步長要買的就是這個。
畫面層不行。這個專案沒有插值,也還沒在高刷新率裝置上實際跑過——tasks.md 的 4.2(跨幀率一致性)到今天還是未勾選。所以我只能說:模擬是穩的,我不能說看起來是穩的。 不同幀率會不會玩出不同勝負,是 Day 19 的題目,要等那項補測做完才寫得成。
這一段最值得講的不是 AI 寫了什麼,是它跟我一起漏掉了什麼。
a20346c 的 commit message 自己指出了原因:267 個單元測試、75 個 E2E 測試,每一個斷言都在檢查 debug 狀態或剛體座標,沒有一個看過像素。測試是我大量指揮 AI 補出來的,補得很勤,覆蓋率也好看——但它跟我一樣,只斷言它看得到的東西,而它看得到的東西是我給它的那份 metadata。
修完之後補上的 tests/unit/linePreviewSync.test.js 換了一個寫法:用剛體的位置當作預期值,去斷言 Graphics 的位置(expect(preview.position.y).toBeCloseTo(body.position.y, 3)),並且真的推進 150 個固定步再檢查。第二個 describe 更直接,把 preview.context.instructions 裡的 stroke 顏色抓出來,斷言深色外框和亮色核心兩個色碼都在。
這才是把「圖跟模擬要同步」變成一個可以被程式判定的東西。 在那之前,它只是一個我以為理所當然的假設,而 AI 沒辦法幫你檢查一個沒有被寫成判準的假設。
一句話:
渲染跟模擬是兩件事,用兩種節奏跑。而它們必須有一個明確的地方接回去,否則錯了不會有人報錯。
三件今天就能做的事:
Runner、搜 Engine.update、搜 ticker.add,每一個都應該只有一個遊戲用途的命中點。明天 Day 11,回到手指。為什麼不用 mousedown 加 touchstart 各寫一套,而是五個 pointer* 事件吃掉滑鼠、觸控與觸控筆;touch-action: none 在這個專案裡被設了兩次(CSS 一次、JavaScript 一次),以及為什麼 lostpointercapture 跟 pointercancel 綁的是同一個 handler。順便講一件不好意思的事:這篇的主題是行動裝置輸入,而我到現在還沒在 iPhone 上跑過它。
本篇數字的快照時間:2026-08-07 12:35(+0800),對應 commit
5aa3705。專案仍在開發中,量體數字會變動;引用的每一項都可以用本文提到的檔案路徑自行對照。
可玩網址:https://save-the-dog-web.vercel.app/|原始碼:https://github.com/HarryFan/save-the-dog-web
如果你卡在語法
深入原理