iT邦幫忙

2026 iThome 鐵人賽

DAY 16
0

模組四|蜜蜂、碰撞與勝負(Day 16–20)

Day 15 講完玩家畫的那條防線為什麼停不下來、以及最直覺的修法會把遊戲弄壞。今天換到線的另一邊:往線上壓的那群蜜蜂。

先攤開事實。這個專案的敵人 AI 沒有 A*、沒有導航網格、沒有行為樹。src/systems/BeeSystem.js 全檔 614 行,真正決定「這一步往哪飛」的是第 463 到 466 行的兩個加號。PRD.md:1856 當初就寫死了這個範圍:「MVP 不需要 A* 尋路或 Navigation Mesh。蜜蜂的遊戲功能是施加壓力,不是精確通過迷宮」。

結論先講:三個向量相加,足以讓敵人看起來有意圖;難的不是演算法,是那二十幾個數字放在哪個檔案、以及誰說了算。 這篇後半段會有一個我自己查了才知道的事實——我以為在調的那份參數表,五個關卡全部把其中兩項覆寫掉了,而覆寫的值跟表上的值方向相反。


三個向量,兩個加號

兩把扇子把我的上半身搧得東倒西歪,我那隻卡在溝裡的腳還是往同一個方向

steer() 一次固定步長跑一次。它先算三個東西,然後把它們加起來:

// src/systems/BeeSystem.js:451-473
  steer(bee, config, deltaMs) {
    const deltaSeconds = deltaMs / 1000
    const seek = this.seekVector(bee, config)
    const separation = this.separationVector(bee, config)

    bee.wanderElapsedMs += deltaMs

    if (bee.wanderElapsedMs >= config.wanderIntervalMs) {
      bee.wanderElapsedMs -= config.wanderIntervalMs
      bee.wanderAngle += bee.random.angle(config.wanderAngle)
    }

    let desiredX =
      seek.x + separation.x * config.separationWeight + Math.cos(bee.wanderAngle) * config.wanderWeight
    let desiredY =
      seek.y + separation.y * config.separationWeight + Math.sin(bee.wanderAngle) * config.wanderWeight

    if (bee.state === BEE_STATE.RECOVERING) {
      // Push sideways relative to the blocked heading so the bee slides out of
      // whatever corner it wedged into.
      desiredX += -seek.y * bee.recoverySign
      desiredY += seek.x * bee.recoverySign
    }

三個向量各自的職責與實作位置:

向量 它算什麼 實作 權重
追蹤 seek 朝「狗狗的預測位置」的單位向量 BeeSystem.js:500-511 1(沒有權重,它是基準)
分離 separation 半徑內每一隻鄰居的反向、距離加權合 :513-539 separationWeight
漫遊 wander 一個持續累加的角度取 cossin :456-461 wanderWeight

seek 沒有權重這件事值得停一下。它是單位向量,另外兩個是疊加在它上面的擾動——這個結構決定了「蜜蜂永遠在追狗」是不變式,其他向量只能讓它偏,不能讓它停。第 468 到 473 行那段是第四個分量,只在卡住復原時出現,那是 Day 18 的題目。

分離向量的加權方式在 :532weight = 1 - distance / separationRadius。距離等於半徑時權重歸零、距離為零時直接跳過(:528),所以它是連續的,不會在邊界上跳動。單元測試只驗它的方向:兩隻蜜蜂擺在 x=300x=306,左邊那隻的分離分量必須是負的、右邊那隻必須是正的(tests/unit/BeeSystem.test.js:323-348)。


加速度上限比速度上限更重要

只夾速度的話,蜜蜂可以在一步之內把速度從 +185 翻成 -185,看起來像瞬移。這段程式碼夾了兩次:

// src/systems/BeeSystem.js:475-497
    const maximumSpeed = config.maximumSpeed / STEPS_PER_SECOND
    const desired = normalize({ x: desiredX, y: desiredY })
    const desiredVelocity = {
      x: desired.x * maximumSpeed,
      y: desired.y * maximumSpeed
    }
    const maximumChange = (config.maximumAcceleration * deltaSeconds) / STEPS_PER_SECOND
    const change = clampMagnitude(
      {
        x: desiredVelocity.x - bee.body.velocity.x,
        y: desiredVelocity.y - bee.body.velocity.y
      },
      maximumChange
    )
    const nextVelocity = clampMagnitude(
      {
        x: bee.body.velocity.x + change.x,
        y: bee.body.velocity.y + change.y
      },
      maximumSpeed
    )

    Body.setVelocity(bee.body, nextVelocity)

先算出「想要的速度」,再算「這一步最多能改變多少」,夾住差值,最後才夾總速度。慣性感來自中間那一夾,不是最後那一夾。兩件事各有一條單元測試守著:tests/unit/BeeSystem.test.js:248 是速度上限,:264 是「單一步長內速度變化量被夾住」。

還有一件不在這段裡的事:轉向只在兩個狀態下有權寫 velocitysrc/entities/Bee.js:10SEEKINGRECOVERING 列成 STEERABLE_STATESBeeSystem.js:362-364 只有在 bee.isSteerable 為真時才呼叫 steer()。蜜蜂撞到防線後進入 HIT,那 130 毫秒裡轉向完全不動速度——:341-342 的註解寫明理由:這段時間速度歸 Matter 的反彈所有,寫進去會把蜜蜂推回線上磨過去。


追的不是現在的狗,是 0.14 秒後的狗

seekVector:500-511)只有一個技巧:目標不是狗狗的位置,是 狗狗位置 + 狗狗速度 × lead,而 lead = predictionSeconds × STEPS_PER_SECOND:505)。

predictionSeconds0.14src/config/game.js:36)。這一行讓蜜蜂看起來像在攔截而不是跟在屁股後面。狗狗在這款遊戲裡多數時間站著不動,所以這個提前量的效果只在狗被蜂群推動、或是被自己的防線撞到的時候才看得出來——它便宜到沒有理由不加,貴的是你要記得它的單位


參數表上寫 px/s,Matter 收的是 px/step

src/config/game.js:20-21 的註解寫著「Speeds are px/s, accelerations px/s²」。Matter 的 Body.setVelocity 收的是 px/step。這中間的換算是一個硬編碼常數:const STEPS_PER_SECOND = 60src/systems/BeeSystem.js:14)。

它在四個地方被除下去:生成時的發射速度(:244)、最高速度(:475)、最大加速度(:481)、預測提前量(:505)。參數表上的單位和引擎的單位不是同一個,這件事沒有型別系統會幫你擋,只能靠所有使用點都記得除。這也是為什麼 60 在這裡是常數而不是從 FIXED_STEP_MS 推回來——它是「參數表的單位定義」,不是「這一幀跑多久」,兩者剛好相等但意義不同。


參數不在我以為的那個檔案裡

我很認真在擦後面那塊上的數字,前面那塊早就把它整個蓋住了

這是我寫這篇時查出來的東西,也是我原本打算寫成另一個樣子的一段。

src/config/game.js:22-61 有一個 BEE_DEFAULTS,二十四個欄位,看起來就是那份參數表。但 BeeSystem 收到的不是它,是它跟關卡資料合併後的結果——BeeSystem.js:107 那一行是 config: { ...BEE_DEFAULTS, ...(hive.bees ?? {}) },關卡那一邊贏。而五個關卡的蜂巢都是 createHive() 建的(src/levels/sharedLevelParts.js:65-79),它會把 SHARED_BEE_TUNING:13-24)整包展開進去。兩份表對照起來是這樣:

參數 game.js BEE_DEFAULTS sharedLevelParts.js SHARED_BEE_TUNING 五關實際生效
separationRadius 48(:40) 42(:20) 42
separationWeight 0.12(:41) 0.45(:21) 0.45
maximumSpeed 185(:34) 185(:17) 185
predictionSeconds 0.14(:36) 0.14(:19) 0.14
radius 22(:25) 未列 22

前兩列的實際生效值,是我把五個關卡各建一次 BeeSystem、把 hives[0].config 印出來量到的【實測】。game.js:37-39 那段註解——「擁擠現在交給物理求解器,分離只留來防止兩隻蜜蜂生在同一點;維持舊權重會跟求解器打架」——描述的是 0.12,而 0.12 沒有到達過任何一隻蜜蜂。那段註解在檔案裡是對的,在遊戲裡是空的。 為什麼會這樣,是 Day 17 的整篇主題。

實務上的教訓很短:設定值有預設層和覆寫層的時候,「這個數字現在是多少」不能用讀的,要用印的。


PRD 給區間,實作給一個值

把 PRD 那張建議表(PRD.md:1636-1655)跟實際生效值放在一起:

參數 PRD 建議 實際生效 判定
每巢蜜蜂數 6–10(:1636) 8(level01level04)/6 + 6(level05 在區間內
生成間隔 180–280 ms(:1639) 230 ms 在區間內
碰撞半徑 14–17 px(:1640) 22 px 超出上限 5 px
一般最高速度 160–210 px/s(:1641) 185 px/s 在區間內
最大加速度 480–650 px/s²(:1643) 560 px/s² 在區間內
frictionAir 0.04–0.08(:1644) 0.06 在區間內
restitution 0.45–0.65(:1645) 0.55 在區間內
目標預測時間 0.10–0.18 秒(:1646) 0.14 秒 在區間內
分離半徑 38–46 px(:1647) 42 px 在區間內
分離權重 0.35–0.55(:1648) 0.45 在區間內
Wander 更新間隔 200–350 ms(:1649) 280 ms 在區間內
Wander 角度 ±0.12–0.22 rad(:1650) 0.18 rad 在區間內

十二項裡十一項落在建議區間,而且多數落在中間附近。唯一破格的是碰撞半徑,game.js:23-24 的註解把理由寫在原地:蜜蜂要在手機尺寸下讀得出來,而且要明顯比防線粗,一堆蜜蜂壓在線上才看得出「重量」。同一段接著寫,放大之後密度必須跟著往下調(:26-31),因為放大是為了好讀、不是為了打得更重。

這張表最反直覺的地方,是它比我預期的無聊。一份寫在開工前的參數區間,事後對照下來十二中十一命中——這不代表區間猜得準,只代表區間開得夠寬(多數是 ±25% 上下)。真正被推翻的那一項,推翻它的理由跟物理無關,是「手機上看不看得清楚」。


上限:24 在把關,16 沒人叫

src/config/game.js:63-64 有兩個常數:BEE_SOFT_LIMIT = 16BEE_HARD_LIMIT = 24

BEE_HARD_LIMIT 有三個呼叫端,各守一件事:單一蜂巢的宣告數會先被夾住(BeeSystem.js:108)、物件池大小不得超過它(:113)、生成迴圈每次都重新檢查存活數(:204)。BEE_SOFT_LIMIT 全 repo 只出現在定義那一行【實測:grep -rn 'BEE_SOFT_LIMIT' src tests openspec docs *.md,只回傳 src/config/game.js:63】。規格裡有、程式裡沒接上,這篇不會假裝它在運作。

實際的池子大小是「這關要幾隻 + BEE_POOL_HEADROOM(4),再跟 24 取小」(:112-115)。量出來是前四關各 12、第五關 16【實測:逐關建 BeeSystembees.length】。這兩個上限是效能預算不是遊戲設計——Day 4 用「範圍要怎麼估」的角度提過同一組數字,這裡看到的是它在蜂群系統裡怎麼被執行的:不是「超過就變慢」,是「超過就不生」


這段我沒有交給 AI

這二十幾個數字,我沒有交給 AI,而且我要先把範圍講清楚。

演算法骨架是規格先行的,有完整紀錄:openspec/changes/archive/2026-08-06-add-bee-and-countdown/ 底下有 proposal.mddesign.mdtasks.md 與三份 spec.md。三向量合成、加速度夾限、只在兩個狀態轉向、卡住偵測的三個條件,全部先寫成 MUST 條款再實作。這一段完全是規格驅動的,寫成「我自己刻的」會是謊。

沒有交出去的是那張表裡的數字。理由具體:調參的迴圈是「改一個數字、玩一次、再改」,這個迴圈裡沒有可自動判定的驗收條件。 「蜜蜂看起來有沒有慣性」「漫遊看起來像不像亂飛」「一坨蜜蜂壓在線上讀不讀得出重量」——這幾句都寫得出來,但沒有一句能寫成 expect(...)。可以自動判定的那幾條都已經在測試裡了(速度上限、加速度上限、分離方向、生成節奏),而它們全部只能證明數字被遵守,不能證明數字選得好。

我沒有 repo 證據能證明哪個數字是誰打的——commit 是整批的,沒有逐次調參的紀錄。能拿來間接支撐的只有一件事:game.js 裡三段解釋「為什麼這個值偏離建議區間」的註解(:23-24:26-31:37-39),內容全是玩起來的觀感——讀不讀得出來、看起來重不重、會不會抖。這些不是從規格推導得出來的東西。這是推論,不是證明。


帶走什麼

一句話:

遊戲 AI 的目標不是聰明,是讓玩家看得懂它在幹嘛;而讓它看得懂的成本,多數花在數字上,不在演算法上。

三件今天就能做的事:

  1. 先把「不變式」跟「擾動」分開。 這裡的 seek 沒有權重,因為它是不變式;分離和漫遊有權重,因為它們只能讓行為偏一點。權重表要不要有這一欄,決定了你半年後看不看得懂自己的加總式。
  2. 夾加速度,不要只夾速度。 慣性感來自「一步能改變多少」,不是「最多能多快」。這兩個上限對應兩條不同的測試。
  3. 設定值有多層覆寫的時候,用印的、不要用讀的。 這篇最大的意外,是一段寫得很清楚的註解描述著一個從沒生效過的值。你的專案裡,有沒有一個「已經改好了」的參數,其實被下游整包覆寫掉了?

明天 Day 17 把上面留下的那個洞挖開。碰撞過濾器通常被當成效能開關,在這個專案裡它從頭到尾是設計工具——src/config/collision.js 的檔頭註解、一個 commit 訊息、一份 openspec 規格,三個地方都寫著「蜜蜂之間要互相碰撞,蜂群才會堆在防線上」。然後我去量了跑起來的那顆 body。


本篇數字的快照時間: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 15|為了躲開一種不穩定,換到了另一種不穩定
下一篇
Day 17|碰撞:我沒有關掉蜜蜂互撞,但跑起來的蜜蜂也沒有真的撞在一起
系列文
一條線救一隻狗:我用 PixiJS、Matter.js 和一條有閘門的 AI 產線做完一款網頁小遊戲17
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言