模組四|蜜蜂、碰撞與勝負(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 |
一個持續累加的角度取 cos/sin |
:456-461 |
wanderWeight |
seek 沒有權重這件事值得停一下。它是單位向量,另外兩個是疊加在它上面的擾動——這個結構決定了「蜜蜂永遠在追狗」是不變式,其他向量只能讓它偏,不能讓它停。第 468 到 473 行那段是第四個分量,只在卡住復原時出現,那是 Day 18 的題目。
分離向量的加權方式在 :532:weight = 1 - distance / separationRadius。距離等於半徑時權重歸零、距離為零時直接跳過(:528),所以它是連續的,不會在邊界上跳動。單元測試只驗它的方向:兩隻蜜蜂擺在 x=300 與 x=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 是「單一步長內速度變化量被夾住」。
還有一件不在這段裡的事:轉向只在兩個狀態下有權寫 velocity。src/entities/Bee.js:10 把 SEEKING 與 RECOVERING 列成 STEERABLE_STATES,BeeSystem.js:362-364 只有在 bee.isSteerable 為真時才呼叫 steer()。蜜蜂撞到防線後進入 HIT,那 130 毫秒裡轉向完全不動速度——:341-342 的註解寫明理由:這段時間速度歸 Matter 的反彈所有,寫進去會把蜜蜂推回線上磨過去。
seekVector(:500-511)只有一個技巧:目標不是狗狗的位置,是 狗狗位置 + 狗狗速度 × lead,而 lead = predictionSeconds × STEPS_PER_SECOND(:505)。
predictionSeconds 是 0.14(src/config/game.js:36)。這一行讓蜜蜂看起來像在攔截而不是跟在屁股後面。狗狗在這款遊戲裡多數時間站著不動,所以這個提前量的效果只在狗被蜂群推動、或是被自己的防線撞到的時候才看得出來——它便宜到沒有理由不加,貴的是你要記得它的單位。
src/config/game.js:20-21 的註解寫著「Speeds are px/s, accelerations px/s²」。Matter 的 Body.setVelocity 收的是 px/step。這中間的換算是一個硬編碼常數:const STEPS_PER_SECOND = 60(src/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.md:1636-1655)跟實際生效值放在一起:
| 參數 | PRD 建議 | 實際生效 | 判定 |
|---|---|---|---|
| 每巢蜜蜂數 | 6–10(:1636) | 8(level01–level04)/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% 上下)。真正被推翻的那一項,推翻它的理由跟物理無關,是「手機上看不看得清楚」。
src/config/game.js:63-64 有兩個常數:BEE_SOFT_LIMIT = 16 與 BEE_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【實測:逐關建 BeeSystem 讀 bees.length】。這兩個上限是效能預算不是遊戲設計——Day 4 用「範圍要怎麼估」的角度提過同一組數字,這裡看到的是它在蜂群系統裡怎麼被執行的:不是「超過就變慢」,是「超過就不生」。
這二十幾個數字,我沒有交給 AI,而且我要先把範圍講清楚。
演算法骨架是規格先行的,有完整紀錄:openspec/changes/archive/2026-08-06-add-bee-and-countdown/ 底下有 proposal.md、design.md、tasks.md 與三份 spec.md。三向量合成、加速度夾限、只在兩個狀態轉向、卡住偵測的三個條件,全部先寫成 MUST 條款再實作。這一段完全是規格驅動的,寫成「我自己刻的」會是謊。
沒有交出去的是那張表裡的數字。理由具體:調參的迴圈是「改一個數字、玩一次、再改」,這個迴圈裡沒有可自動判定的驗收條件。 「蜜蜂看起來有沒有慣性」「漫遊看起來像不像亂飛」「一坨蜜蜂壓在線上讀不讀得出重量」——這幾句都寫得出來,但沒有一句能寫成 expect(...)。可以自動判定的那幾條都已經在測試裡了(速度上限、加速度上限、分離方向、生成節奏),而它們全部只能證明數字被遵守,不能證明數字選得好。
我沒有 repo 證據能證明哪個數字是誰打的——commit 是整批的,沒有逐次調參的紀錄。能拿來間接支撐的只有一件事:game.js 裡三段解釋「為什麼這個值偏離建議區間」的註解(:23-24、:26-31、:37-39),內容全是玩起來的觀感——讀不讀得出來、看起來重不重、會不會抖。這些不是從規格推導得出來的東西。這是推論,不是證明。
一句話:
遊戲 AI 的目標不是聰明,是讓玩家看得懂它在幹嘛;而讓它看得懂的成本,多數花在數字上,不在演算法上。
三件今天就能做的事:
seek 沒有權重,因為它是不變式;分離和漫遊有權重,因為它們只能讓行為偏一點。權重表要不要有這一欄,決定了你半年後看不看得懂自己的加總式。明天 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
如果你卡在語法
Body 文件(Body.setVelocity 的單位是 px/step)
Math.hypot:向量長度不必自己寫平方和開根號
Object.freeze:把設定表凍起來,避免下游偷改
深入原理