iT邦幫忙

2026 iThome 鐵人賽

DAY 18
0

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

Day 17 講完蜜蜂撞上防線之後會發生什麼:進 HIT 狀態 130 毫秒、轉向停手、讓 Matter 的反彈決定牠彈去哪裡。今天講另一種下場——彈不出去

玩家畫的線常常有凹角。蜜蜂朝狗狗飛,鑽進兩段之間的夾角,速度掉到接近零,但目標還在遠方,轉向會繼續把牠往裡面推。這時候沒有人贏、沒有人輸,倒數還在跑,畫面上就是一隻蜜蜂卡在那裡抖。

這不是引擎的 bug。Day 16 那套三向量沒有繞路能力——seek 是一個朝著目標的單位向量,它不知道中間有東西,也不知道自己剛剛已經撞過同一個地方五十次。有沒有障礙、要不要繞,那是尋路演算法的工作,而 PRD.md:1856 一開始就把尋路排除在 MVP 之外。這是推論:repo 裡沒有截圖、issue 或紀錄佐證這個現象真的在遊玩中發生過,它是我從機制推出來的失效模式。

結論先講:任何啟發式的脫困機制,都必須有一個保證會結束的出口;而這個專案最值得看的一段,是它把規劃中的第二級復原整個拿掉了——而且拿掉的動作留在規格裡,不是留在程式碼裡。


卡住的形狀不是「1.5 秒內總位移小於 8 px」

我推得很用力,門也一直在轉,腳印卻繞成一個圓

PRD.md:1846 那句話是這樣寫的:「每 500 ms 記錄蜜蜂位置。若連續 1.5 秒位移小於 8 px、與狗狗距離大於 80 px」。

實作出來的條件比這句話寬,而且寬得剛好:

// src/systems/BeeSystem.js:414-437
    const displacement = Math.hypot(
      bee.body.position.x - previous.x,
      bee.body.position.y - previous.y
    )
    const dogDistance = this.dogBody
      ? Math.hypot(
          bee.body.position.x - this.dogBody.position.x,
          bee.body.position.y - this.dogBody.position.y
        )
      : Infinity

    const stuck =
      displacement < config.stuckDisplacementThreshold && dogDistance > config.stuckDogDistance

    if (!stuck) {
      bee.stuckElapsedMs = 0
      return
    }

    bee.stuckElapsedMs += config.stuckSampleIntervalMs

    if (bee.stuckElapsedMs < config.stuckDurationMs) {
      return
    }

displacement 量的是這一個 500 毫秒區間的頭尾距離,不是 1.5 秒的總位移。累加器 stuckElapsedMs 每通過一次取樣就加 500,連續三次滿 1500 才升級(stuckDurationMs: 1500src/config/game.js:55);任何一次取樣超標,累加器整個歸零(:428-431)。

差別在哪裡?「1.5 秒總位移小於 8 px」抓不到原地來回震盪的蜜蜂——牠三個區間各跑 7 px,總位移可能有 21 px,卻哪裡也沒去。實作的版本抓得到,因為它問的是「每一段有沒有前進」,不是「整段有沒有位移」。 這才是凹角卡住的真實形狀:不是靜止,是徒勞。

三個條件裡最容易被忽略的是第三個:與狗狗距離要大於 80 px(stuckDogDistancegame.js:56)。貼在狗旁邊不動的蜜蜂不算卡住——牠已經到目的地了,這時候把牠往旁邊推是幫倒忙。這條有專門的測試守著(tests/unit/BeeSystem.test.js:480-509,測試名字直接寫「不對坐在狗狗旁邊不動的蜜蜂啟動復原」)。

還有一個前提在更前面:只有 SEEKING 狀態的蜜蜂會被判定,其他狀態一律把累加器歸零(:409-412)。SPAWNING 的蜜蜂還在滑出蜂巢、HIT 的蜜蜂正在被彈開、RECOVERING 的蜜蜂本來就在脫困中——這三種「不動」都不是卡住。


規劃三級,實作兩級

PRD.md:1848-1854 把復原寫成七個步驟,分成三級:第 1–4、6 步是第一級(切向推力),第 5 步是第二級,第 7 步是第三級(回收重生)。對照程式碼:

PRD 步驟 程式碼在哪
1. 狀態改為 RECOVERING BeeSystem.js:448
2. 找出朝目標向量的左或右垂直向量 :471-472
3. 依 Seed 隨機選方向 :447bee.random.pick([-1, 1])
4. 在 300–500 ms 內混入切向速度 :354,時間是單一值 recoveryDurationMs: 400
5. 暫時提高反彈係數或降低追蹤權重 找不到
6. 返回 SEEKING :355
7. 三次失敗回收重生,給 400 ms 無傷害 :441-443:294-295

第五步不存在。這不是「我沒找到」——RECOVERING 在整個 src/ 只出現在五個地方,其中三個在 BeeSystem.js(進入 :448、退出 :354、側推 :468),另外兩個是列舉定義(src/entities/Bee.js:4:10);restitution 只在建立 body 時寫入一次(BeeSystem.js:137),之後從未被修改;追蹤權重根本不存在,seek 是沒有權重的那一個【實測:grep -rn 'RECOVERING' srcgrep -rn 'restitution' src】。

有趣的是拿掉它的地方。openspec/changes/archive/2026-08-06-add-bee-and-countdown/specs/bee-swarm/spec.md:136 那條 MUST 是這樣寫的:

BeeSystem MUST 每 500 ms 取樣一次蜜蜂位置。當一隻 SEEKING 蜜蜂連續 1.5 秒位移小於 8 px、且與狗狗距離大於 80 px 時,MUST 轉入 RECOVERING,於 300–500 ms 內疊加由 seeded random 決定方向的切向推力,之後回到 SEEKING

第五步在這句話裡已經沒有了。PRD 是意圖,openspec 的 spec 是合約——被拿掉的那一級,是在從意圖翻成合約的那一步消失的,程式碼只是照著合約做。 為什麼被拿掉,proposal.mddesign.md 都沒有寫,我也沒有印象。這篇不會編一個「我試過發現沒用」的故事出來——我唯一能證明的是它在規格裡就不見了,不是在實作時被偷工。


第一級是加法,不是替換

規劃裡的第二級(降低追蹤權重)和實作的第一級(切向推力),方向其實相反。看那個運算子:

// src/systems/BeeSystem.js:466-473
    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
    }

+=,不是 =復原期間蜜蜂沒有停止追狗seekseparationwander 照常算,只是額外收到一個垂直於 seek 的分力。(-seek.y, seek.x)seek 逆時針轉 90 度的結果,recoverySign 決定往左還是往右。

「削弱目標」與「保持目標、加一個橫向力」是兩種策略,不是同一策略的兩個強度。前者讓蜜蜂在凹角裡慢下來,後者讓牠沿著牆滑出去——這個差別在規格文件上看不出來,只有讀到那兩個字元才看得到。

順帶一提,src/config/game.js:58 有一個 recoveryImpulse: 150,看起來像是控制這個推力的大小。它全 repo 零使用【實測:grep -rn 'recoveryImpulse' src tests openspec docs *.md,只回傳定義那一行】。切向分量的實際大小由整個 desired 正規化之後乘上 maximumSpeed 決定(:476-480)——這個常數宣告了一個不存在的旋鈕,跟 Day 16 那個沒人叫的 BEE_SOFT_LIMIT 是同一種東西。


保證終止的出口

三根撬棍都扳彎了,第四次我不撬了,直接把它整個舀回起點

啟發式的脫困有可能失敗。橫向推 400 毫秒之後,蜜蜂可能滑出去,也可能滑進更深的角落。所以升級判定的第一件事不是「再推一次」:

// src/systems/BeeSystem.js:439-448
    bee.stuckElapsedMs = 0

    if (bee.recoveryAttempts >= config.maximumRecoveryAttempts) {
      this.respawn(bee)
      return
    }

    bee.recoveryAttempts += 1
    bee.recoverySign = bee.random.pick([-1, 1])
    bee.setState(BEE_STATE.RECOVERING)

maximumRecoveryAttempts 是 3(game.js:59)。第四次判定卡住時,這隻蜜蜂被 respawn():283-296)整個收回蜂巢重來,並拿到 400 毫秒的無傷害時間——用 Day 17 那個手法,暫時把狗從牠的碰撞遮罩拿掉。

重生不試圖解決卡住,它讓那隻蜜蜂不再存在於那個位置。 這是整套機制裡唯一保證會結束的一步,也是我認為這篇最該帶走的一條:任何基於啟發式的行為都要有重試上限,還要有一個不依賴啟發式的退路。沒有退路的復原機制,會把偶發問題變成永久卡死。

recoveryAttempts 不隨時間衰減,只在 Bee.reset()src/entities/Bee.js:42-54)歸零,而 reset() 只在生成與回收時呼叫——所以一隻蜜蜂最多脫困三次。


那個 V 形凹角,我沒遇過,測試遇過

tests/unit/BeeSystem.test.js:427-478 這條測試的名字是「escapes a V-shaped pocket instead of sitting there until the countdown ends」。它用三個點(300,600375,700450,600)造出一個 V 形防線,把一隻蜜蜂放在 375,672、速度歸零,然後跑最多 900 步,斷言牠必須離開那個口袋。

這條測試對應規格裡的一個 Scenario(bee-swarm/spec.md:138-142:凹角脫困)。它是這個現象唯一的紀錄——不是我玩到卡住才補的,是規格先寫了 Scenario、測試照著寫。

這跟直覺相反:這類復原機制通常是被 bug 報告逼出來的,這裡不是。代價也很明確——沒有人真的看過牠卡住,所以沒有人知道 8 px 和 1.5 秒是不是對的數字


物件池與那個左右隨機

重生用的是回收的物件,不是 newBeeSystem.createPool():118-157)的註解寫著「預先配置這一關可能用到的每一個 body 與 sprite」,respawn() 上面那行寫著「Recycles a bee straight back into its hive: body count never changes」(:283)。取池就是找第一個 DESPAWNED 的(:220-222)。

池子大小是「這關要幾隻 + BEE_POOL_HEADROOM(4),再跟硬上限 24 取小」(:112-115),量出來前四關各 12、第五關 16【實測】——那個 headroom 就是留給復原失敗後重生的。這是設計理由,不是量測結果:「反覆生成銷毀會造成 GC 尖峰」這個專案沒量過。

:447 那個 bee.random.pick([-1, 1]) 值得單獨提一句。它不是 Math.random(),是每隻蜜蜂自己的 seeded 隨機串流(:149this.random.fork('bee-' + index))。規格層有 MUST(spec.md:136 明寫方向由 seeded random 決定),工程層有 lint 護欄:eslint.config.js:64-70MemberExpression 選擇器擋 Math.random,連 const r = Math.random 這種別名也擋得到。左右選錯不會讓遊戲壞掉,但會讓同一局重播出現不同結果——那是明天的題目。


這段我沒有交給 AI

跟 Day 16 一樣,這裡要先界定範圍:卡住偵測的骨架是規格先行的,三個條件、1.5 秒門檻、三次上限、400 毫秒無敵,全部先寫成 MUST 與 Scenario(bee-swarm/spec.md:134-158)再實作,也全部有對應的單元測試。這一段完全交得出去。

沒有交出去的是那三個數字:500、8、1500。理由跟 Day 16 同一個——這個迴圈裡沒有可自動判定的驗收條件。門檻放寬會誤判正在纏鬥的蜜蜂、放嚴會漏掉真的卡住的,而這兩種錯在畫面上都不會報錯,只會讓遊戲看起來怪。要判斷它對不對,得畫一條有凹角的線、玩一次、看那隻蜜蜂脫困的樣子像不像回事,再改一個數字重來。

這裡也有一個我要誠實說的限制:這三個數字最後跟 PRD.md:1651-1653 的建議值一模一樣(500 ms、8 px、1.5 秒)。所以我甚至不能說「我調出來的」——可查的事實是它們沒有被改過,至於是「試過覺得剛好」還是「沒試就照抄」,repo 裡沒有任何東西能分辨這兩者。我傾向前者,但那是回憶,不是證據。


帶走什麼

一句話:

啟發式的脫困可以失敗,所以它必須有次數上限,以及一個不依賴啟發式的退路;沒有退路的復原機制,會把偶發問題變成永久卡死。

三件今天就能做的事:

  1. 把「卡住」定義成「每一段有沒有前進」,不是「整段有沒有位移」。 前者抓得到原地徒勞,後者抓不到。差別只是累加器要不要在每次取樣時歸零。
  2. 給復原加一個計數器和一個出口。 這裡是三次然後回收重生。重生不解決問題,它讓問題所在的那個實例消失——這通常比繼續嘗試便宜得多。
  3. 翻一次你的規格到程式碼的落差。 這個專案的第五步在「意圖翻成合約」那一步就沒了。你的 PRD 寫了幾條,你的驗收條件寫了幾條,兩個數字一樣嗎?

明天 Day 19 把今天那個 bee.random.pick([-1, 1]) 拉到台前。同一條線、同一個關卡,在別人的手機上會不會跑出不同的勝負?這個專案的答案分成兩半:模擬層有一整條從亂數源到容器迭代順序的鏈在守著它,而那條鏈的保證範圍只到「同一個 JavaScript 引擎之內」——再往外,靠的不是位元一致。


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

尚未有邦友留言

立即登入留言