iT邦幫忙

2026 iThome 鐵人賽

DAY 17
0
JavaScript

一條線救一隻狗:我用 PixiJS、Matter.js 和一條有閘門的 AI 產線做完一款網頁小遊戲系列 第 17

Day 17|碰撞:我沒有關掉蜜蜂互撞,但跑起來的蜜蜂也沒有真的撞在一起

  • 分享至 

  • xImage
  •  

模組四|蜜蜂、碰撞與勝負(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 建在別的地方時,兩邊可以長時間不一致——不一致的時候沒有東西會報錯,測試也不會紅。


五個 category、四組 mask,一個雙向檢查

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

蜜蜂的 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 0x1aDOG + LINE + PLATFORM),COLLISION_FILTER.bee.mask0x1e
兩隻重疊蜜蜂跑 60 步的間距 用 body 上那組 filter:5.00 px → 5.00 px;用 COLLISION_FILTER.bee5.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 是為這件事新增的測試,兩個核心斷言長這樣:

  • :11COLLISION_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 的 collisionFilterCOLLISION_FILTER.bee 比對。這個 repo 現在沒有這一條。我不打算把它寫成「我早就想到」,我是量完才知道要加哪一條。


無敵幀:碰撞層本身就是狀態

collisionFilter 可以當遊戲機制用,這個專案有一個現成例子。蜜蜂重生後有 400 毫秒的無傷害時間(invulnerableMssrc/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。一旦進了 WINLOSE,之後所有事件原地丟掉。加上 BeeSystem.js:301-303 那道「已經在 HIT 狀態就忽略」的第二層,283 次事件才不會變成 283 次結算。

去重放在一層、而不是每個事件處理器各做一次,這件事比「有去重」重要得多。 第二個失敗條件(狗離開世界邊界,CollisionSystem.js:73-91)什麼都沒做就繼承了同一道守衛。至於同一步裡同時出現贏和輸要算誰的,那是另一個問題、另一個機制,Day 20 會講。


帶走什麼

一句話:

碰撞過濾器不是效能開關,是設計工具;而它是一份宣告,宣告放在設定檔,body 建在別的地方——兩邊不一致的時候,不會有人跟你說。

三件今天就能做的事:

  1. 記住 canCollide 是雙向的。 關掉一對只要改一邊,打開一對要改兩邊。用一個具名函式把這個規則寫下來,比註解有用。
  2. 測試要對著建構路徑測,不要對著常數測。 「設定值正確」與「跑起來的物件拿到那個設定值」是兩條斷言。前者好寫,後者才是你要的。
  3. 去重只放一層。 把「這次算不算」的鎖集中在狀態機入口,新的失敗條件就自動繼承。你的專案裡,這個鎖是一份,還是散在每個事件處理器裡的 N 份?

明天 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

參考資料

如果你卡在語法

深入原理


上一篇
Day 16|蜜蜂怎麼追人:三個向量加起來就夠了
系列文
一條線救一隻狗:我用 PixiJS、Matter.js 和一條有閘門的 AI 產線做完一款網頁小遊戲17
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言