模組四|蜜蜂、碰撞與勝負(Day 16–20)
Day 16 結尾留了一個洞:src/config/game.js:37-39 有一段註解,寫著「擁擠現在交給物理求解器,分離向量只留來防止兩隻蜜蜂生在同一點」,而它描述的那個值(separationWeight: 0.12)被五個關卡整包覆寫成 0.45,從來沒有到達過任何一隻蜜蜂。
今天把這個洞挖到底。
先攤開事實。這個專案裡有三個地方寫著「蜜蜂之間要互相碰撞」:src/config/collision.js:4-8 的檔頭註解、commit fff7160 的標題「讓蜜蜂互相碰撞,蜂群才會堆在防線上」、以及 openspec/changes/align-with-reference-game/specs/bee-swarm/spec.md 裡一整條 MUST。三個都是真的、都還在 repo 裡。
然後我去量了跑起來的那顆 body。在快照 5aa3705 上,蜜蜂的碰撞遮罩不含蜜蜂,跑完一整關的示範解,蜜蜂對蜜蜂的碰撞對數是 0。
結論先講:collisionFilter 是設計工具,不是效能開關;但它同時是一個「宣告」,而宣告寫在設定檔、body 建在別的地方時,兩邊可以長時間不一致——不一致的時候沒有東西會報錯,測試也不會紅。
Matter 的碰撞過濾用兩個位元欄位:category 說「我是什麼」,mask 說「我要跟什麼碰」。
// src/config/collision.js:10-16、:23-30、:45-47(中間省略)
export const COLLISION_CATEGORY = Object.freeze({
DEFAULT: 0x0001,
DOG: 0x0002,
BEE: 0x0004,
LINE: 0x0008,
PLATFORM: 0x0010
})
bee: Object.freeze({
category: COLLISION_CATEGORY.BEE,
mask:
COLLISION_CATEGORY.BEE |
COLLISION_CATEGORY.DOG |
COLLISION_CATEGORY.LINE |
COLLISION_CATEGORY.PLATFORM
}),
export function canCollide(first, second) {
return Boolean(first.mask & second.category) && Boolean(second.mask & first.category)
}
canCollide(:45-47)是這個檔案裡最容易被忽略的三行:它是雙向的。要讓 A 撞 B,A 的 mask 要含 B 的 category,B 的 mask 也要含 A 的 category。想關掉一對碰撞,改單邊就夠了;想打開一對碰撞,兩邊都得改。這個不對稱是後面所有麻煩的源頭。
四組 filter 裡有一個刻意的缺口:平台的 mask 只有 DOG | BEE | LINE(:39-42),平台之間不互撞。它們都是靜態剛體,互撞本來也不會發生,寫進去只是把意圖講明白。
這個專案原本的規格明文禁止蜜蜂互撞。openspec/changes/archive/2026-08-06-add-bee-and-countdown/specs/bee-swarm/spec.md:97 那條 Requirement 標題就是「蜜蜂之間不得進行完整物理碰撞」,底下的 Scenario 寫「兩隻蜜蜂的 body 在空間上重疊,Matter MUST NOT 為這一對產生碰撞解算」。
後來這條被整條撤掉了。openspec/changes/align-with-reference-game/specs/bee-swarm/spec.md 是一份 REMOVED + ADDED 的規格差異,撤除理由逐字寫著:關閉 bee-to-bee 碰撞使蜂群只能形成鬆散的細流,永遠不會積成一坨,玩家也就永遠感受不到「防線快撐不住」;原需求源自 PRD 的「可關閉 Bee-to-Bee 碰撞」,那是選項而非規範。
src/config/collision.js:4-8 的檔頭註解是同一件事的程式碼版本:蜂群要能在防線上堆起來,那個堆積就是遊戲的張力來源;擁擠是求解器的工作,AI 分離力降權,只留來防止蜜蜂生在同一點。
這是一個把效能考量讓位給遊戲手感的決定,而且理由被寫在三個地方。 到這裡為止,這篇本來要寫的是「該關的沒關」。
蜜蜂的 body 不是用 COLLISION_FILTER.bee 建的。BeeSystem.createPool() 自己寫了一組 filter:
// src/systems/BeeSystem.js:126-140
const body = Bodies.circle(-1000 - index * 50, -1000, config.radius, {
label: BODY_LABEL.BEE,
// A fresh filter object per bee: invulnerable bees temporarily drop the
// dog out of their mask, which must not leak into other bees.
collisionFilter: {
category: COLLISION_CATEGORY.BEE,
mask:
COLLISION_CATEGORY.DOG | COLLISION_CATEGORY.LINE | COLLISION_CATEGORY.PLATFORM
},
density: config.density,
frictionAir: config.frictionAir,
restitution: config.restitution,
// Bees are steered every step, so they must never be put to sleep.
sleepThreshold: Infinity
})
註解解釋的是「為什麼每隻蜜蜂要有自己的 filter 物件」——無敵期間要把狗從 mask 拿掉,不能外洩到別隻。這個理由成立。但這組手寫的 mask 少了 COLLISION_CATEGORY.BEE,而且 updateInvulnerability()(:386-394)每次重算 mask 時用的 baseMask 也只有 LINE | PLATFORM——就算有人去補 createPool,無敵計時一跑就會被蓋回去。
三組實測,都在 vitest 的 headless 環境裡跑,用關卡自己的 seed:
| 量什麼 | 結果 |
|---|---|
| 蜜蜂 body 的 mask | 0x1a(DOG + LINE + PLATFORM),COLLISION_FILTER.bee.mask 是 0x1e |
| 兩隻重疊蜜蜂跑 60 步的間距 | 用 body 上那組 filter:5.00 px → 5.00 px;用 COLLISION_FILTER.bee:5.00 px → 43.50 px |
level01 示範解跑 1200 個固定步 |
蜜蜂↔蜜蜂的 collisionStart 對數 0;蜜蜂↔防線 283;結束時最近的一對蜜蜂相距 5.08 px |
最後那個數字最直白:兩隻半徑 22 px 的蜜蜂剛好接觸時,圓心距離是 44 px。量到 5.08 px 的意思是牠們幾乎重疊在一起。蜂群沒有堆在防線上,牠們疊在防線上。
我要在這裡停一下講清楚我不知道什麼。openspec/changes/align-with-reference-game/tasks.md:3 那條「bee.mask 加入 COLLISION_CATEGORY.BEE」是打勾的,而它確實做了——做在 collision.js 裡。:37 的「實機截圖人工覆核:蜜蜂確實堆疊」也是打勾的。這兩件事跟我量到的結果並存,我沒有紀錄可以解釋中間發生了什麼,所以這篇只寫機制,不寫動機,也不寫「誰漏了什麼」。

tests/unit/swarmStacking.test.js 是為這件事新增的測試,兩個核心斷言長這樣:
:11:COLLISION_FILTER.bee.mask & COLLISION_CATEGORY.BEE 等於 COLLISION_CATEGORY.BEE。:14-62:五隻蜜蜂從空中落到平台上,跑 900 步之後任兩隻的距離都要大於 radius * 2 - 2,而且整堆要有高度。第二條是貨真價實的物理測試,它會抓到「求解器沒把牠們推開」。問題在 :25——那五顆 body 是測試自己用 Bodies.circle 建的,collisionFilter 直接填 COLLISION_FILTER.bee。測試證明了那個常數是對的、Matter 的行為是對的,沒有證明遊戲裡的蜜蜂拿得到那個常數。
這是我在這個專案裡看過最乾淨的一個「測試通過但功能沒上線」的形狀,而且它不需要任何人犯低級錯誤:測試要驗物理,就得自己造一個乾淨的場景;自己造場景,就會自己填設定;自己填設定,就跳過了真正在跑的那條建構路徑。
有一條可以自動守住這件事的斷言,寫起來只要一行——把 BeeSystem 建出來的 body 的 collisionFilter 跟 COLLISION_FILTER.bee 比對。這個 repo 現在沒有這一條。我不打算把它寫成「我早就想到」,我是量完才知道要加哪一條。
collisionFilter 可以當遊戲機制用,這個專案有一個現成例子。蜜蜂重生後有 400 毫秒的無傷害時間(invulnerableMs,src/config/game.js:60),實作方式不是加一個「這隻不算數」的旗標,是暫時把狗從牠的 mask 拿掉(BeeSystem.js:386-394):無敵時 mask = LINE | PLATFORM,時間到就補回 DOG。
差別在於,加旗標的話,每一個「碰到狗」的判定點都要記得檢查它;改 mask 的話,那對碰撞根本不會被 Matter 產生出來,下游沒有東西需要知道無敵這回事。單元測試守著兩端(tests/unit/BeeSystem.test.js:539-564):無敵中 mask 的 DOG 位元必須是 0,窗口關閉後必須回來。
這是碰撞過濾器最好的用法:不是問「這次碰撞算不算」,是讓不算的那次碰撞不要發生。

物理引擎給的是「這兩顆 body 碰到了」,不是「遊戲該怎麼反應」。中間那層在 src/systems/CollisionSystem.js:
// src/systems/CollisionSystem.js:48-70
translatePair(bodyA, bodyB) {
const labelA = labelOf(bodyA)
const labelB = labelOf(bodyB)
const bee =
labelA === BODY_LABEL.BEE ? bodyA : labelB === BODY_LABEL.BEE ? bodyB : null
if (!bee) {
return
}
const otherLabel = labelA === BODY_LABEL.BEE ? labelB : labelA
const payload = { beeIndex: beeIndexOf(bee) }
if (otherLabel === BODY_LABEL.PLAYER_LINE) {
this.eventBus.emit(GAME_EVENT.BEE_HIT_LINE, payload)
return
}
if (otherLabel === BODY_LABEL.DOG) {
this.eventBus.emit(GAME_EVENT.BEE_HIT_DOG, payload)
}
}
labelOf 背後是 rootBody(:4-6):防線是複合剛體,Matter 給的碰撞對是零件,要先映回 parent 才知道「這是防線」。防線為什麼是複合剛體是 Day 15 的題目,這裡只用它的一個後果——同一隻蜜蜂可以在同一步碰到相鄰的兩段。
這個後果我量到了。level01 的示範解抽稀後是 24 段(line.parts.length - 1),跑 1200 個固定步共發出 283 次 BEE_HIT_LINE,其中有 52 個步長裡,同一隻蜜蜂在同一步發了兩次,單步最大值就是 2【實測】。
而 CollisionSystem 完全不管這件事。檔頭註解(:16-22)把分工寫死了:Deliberately stateless: it does not deduplicate and it never changes game state.——把結果鎖成單一結局是 GameStateMachine 的工作,這樣每一個新的失敗條件都自動繼承這道守衛。
那道守衛是 GameStateMachine.handle() 的前三行(src/core/GameStateMachine.js:67-70):if (this.isTerminal) return this.state。一旦進了 WIN 或 LOSE,之後所有事件原地丟掉。加上 BeeSystem.js:301-303 那道「已經在 HIT 狀態就忽略」的第二層,283 次事件才不會變成 283 次結算。
去重放在一層、而不是每個事件處理器各做一次,這件事比「有去重」重要得多。 第二個失敗條件(狗離開世界邊界,CollisionSystem.js:73-91)什麼都沒做就繼承了同一道守衛。至於同一步裡同時出現贏和輸要算誰的,那是另一個問題、另一個機制,Day 20 會講。
一句話:
碰撞過濾器不是效能開關,是設計工具;而它是一份宣告,宣告放在設定檔,body 建在別的地方——兩邊不一致的時候,不會有人跟你說。
三件今天就能做的事:
canCollide 是雙向的。 關掉一對只要改一邊,打開一對要改兩邊。用一個具名函式把這個規則寫下來,比註解有用。明天 Day 18 講蜜蜂撞上防線之後的另一種下場:卡住。玩家畫的線常有凹角,蜜蜂朝狗狗飛、鑽進去、速度接近零,遊戲沒輸沒贏就停在那裡。PRD.md:1848-1854 為此規劃了三級復原,程式碼交出來的是兩級——少的那一級不是被忘記,是在規格階段就被拿掉了,而拿掉它的那份規格還在 repo 裡。
本篇數字的快照時間:2026-08-07 12:35(+0800),對應 commit
5aa3705。專案仍在開發中,量體數字會變動;引用的每一項都可以用本文提到的檔案路徑與 commit 自行對照。
可玩網址:https://save-the-dog-web.vercel.app/|原始碼:https://github.com/HarryFan/save-the-dog-web
如果你卡在語法
Body 文件:collisionFilter 的 category/mask/group 三個欄位
Detector 文件:Detector.canCollide 的實際判定式
a & b
深入原理
Events 文件:collisionStart 給的是 pair 陣列,不是「一次撞擊」