iT邦幫忙

2026 iThome 鐵人賽

DAY 15
0

模組三|畫線:從手指到剛體(Day 10–15)

昨天的抽稀管線交出最多 64 個點。今天這些點要變成 Matter.js 世界裡「擋得住蜜蜂」的東西。

這是整個專案最重要的技術決定,PRD.md:9 的摘要寫在第一段:MVP 的防護線先做成單一複合剛體,柔性繩索延後到第二階段。Day 2 提過這條 MUST NOT,講的是規格如何在事前關掉一條路。今天講物理:關掉那條路之後,換到的是什麼。

結論先講:複合剛體換掉的是「約束誤差沿鏈條累積」這一種不穩定,不是全部的不穩定。 它自己還留了一個,而且是靠專案自己寫的一個函式壓下去的,不是靠引擎參數。更糟的是,最直覺的那個修法會讓遊戲完全不能玩,且不會報任何錯。


沒走的那條路,以及我為什麼不能描述它

直覺做法是:每兩點之間放一個小方塊,方塊之間用 Constraint 連起來,像一條會垂墜的鏈子。

這條路一行程式碼都沒有寫過。三個方向都可以驗:

驗法 結果
歸檔規格明文排除 openspec/changes/archive/2026-08-05-add-drawing-system/specs/drawing-system/spec.mdit MUST NOT be modeled as a flexible constraint chain in the MVP
現行原始碼 grep -rn 'Constraint' src/0 個命中
全部歷史 git log -S'Constraint' --oneline -- src/0 個命中

同案的設計文件是同一句話:The MVP player line is a single compound body made from short chamfered rectangles. It is not a flexible rope.

所以上半段講的是一個沒有被執行的選項為什麼被排除,不是兩種做法的比較。我不能說「我試過繩索版,它會抖」,因為我沒有試過。

排除理由的出處在 PRD.md:96

Matter.js 的 Constraint 用於維持兩個剛體或剛體與世界座標間的固定距離,並可透過 stiffness 形成彈性;這正是柔性繩索會增加數值穩定度與效能風險的原因,所以第一版不應讓每一小段線條都自由擺動。

這一行在 PRD 裡帶著網路引用標記——它是讀文件推導出來的判斷,不是量出來的。所以「幾十個約束串起來會不穩」在這篇文章裡一律標【推論】。

我到今天也不知道繩索版實際會有多糟——這是這個決定的代價。

還有一件事要一起講,因為它容易被寫成不誠實的版本。PRD.md:2056 的效能預算表訂了「Constraint 數:MVP 近乎 0」,但這個專案從頭到尾沒有調過任何求解迭代參數positionIterationsvelocityIterationsconstraintIterationssrc/tests/ 全庫 0 命中【實測】。所以我不能寫「提高迭代數的代價是效能,所以我沒提高」——我沒走到需要考慮它的地方。


一個沒有人讀的欄位

規格先行會在資料層留下疤。src/levels/sharedLevelParts.js:46,五關共用的畫線參數表第一行寫著 mode: 'rigid'——而這個欄位src/ 沒有任何一行程式碼讀它【實測】。它預留了「以後會有第二種 mode」的位置,而那個 mode 不存在。這不是 bug,是一個誠實的痕跡:規格層做過的取捨,會以「一個沒人讀的欄位」這種形式留在資料裡。


走的那條路:每一段一個矩形

src/systems/createPlayerLineBody.js 的核心是一個迴圈,每兩個相鄰點造一個矩形。迴圈每一輪先算出兩點的差 deltaXdeltaY 與距離 length,然後(:39-60):

    if (length < 1) {
      continue
    }

    const part = Bodies.rectangle(
      (start.x + end.x) / 2,
      (start.y + end.y) / 2,
      length + 3,
      thickness,
      {
        angle: Math.atan2(deltaY, deltaX),
        ...optionalOptions,
        friction: 0.75,
        frictionStatic: 0.9,
        restitution: 0.05,
        density: LINE_DENSITY,
        label: `${label}-segment`
      }
    )

    parts.push(part)
  }

最後 Body.create({ parts }) 把它們併成一個。三個細節值得指出來:

細節 行為 為什麼
length + 3 每段比實際距離長 3px 相鄰段重疊,接縫不會留縫給蜜蜂鑽
if (length < 1) continue 太短的段直接跳過 否則複合剛體會塞滿無效零件
chamfer 角落倒圓,半徑 Math.min(thickness / 2, 8),厚度 12 時是 6 減少尖角互卡

厚度這件事我在大綱裡寫錯了,而且錯得剛好可以拿來講。大綱寫「防線厚度 16px,這是防穿透的參數,不是視覺參數」。實際上 16 只是 createPlayerLineBody.js:12DrawingSystem.js:19 的 fallback 預設值,五個關卡沒有一個用到它;關卡實際用的是 12(src/levels/sharedLevelParts.js:49),而那一行的註解寫的是 Thinner than a bee on purpose: the swarm should look heavy on the line.

原始碼直接反駁了我的斷言。 選 12 的理由記在註解裡的是視覺——線要比蜜蜂細,蜂群壓上去才看得出重量。厚度被防穿透與視覺兩個需求拉扯,這個專案往視覺那邊靠。

至於防穿透夠不夠,可以做一個紙上算術,但這是【推論】不是實測:蜜蜂最高速 185 px/s(src/config/game.js),固定步長 1/60 秒,一步最多移動約 3.1px,遠小於 12px。實際會不會穿透還牽涉碰撞解算與蜂群堆疊時的推擠,我沒有量過。

跟厚度成對的是密度:線 0.003,蜜蜂 0.00014,差 21 倍——線必須在蜂群壓上來時待在原地。蜜蜂那一半留到 Day 17。


複合剛體換到的代價:它是一根鐵條

「整條線是一個剛體」的完整意思是:它不能彎折。 落下時整條一起落,撞到東西時整條一起轉,一端被壓下去時另一端會翹起來。玩家畫的是一條曲線,物理上得到的是一根照著那條曲線折彎的鐵條。

畫面也跟著走。DrawingSystem.jsdrawLockedPreview() 把預覽線繞著剛體質心重畫,然後 this.preview.rotation = body.angle——整張圖用一個角度旋轉。線若是柔性的,這一行就不成立,畫面得逐節點跟著走。

chamfer 的代價也在這裡。倒圓角讓端點不會卡住,但也讓端點變成兩個圓弧的支點。專案的單元測試裡有一段註解把這個講得很直白(tests/unit/lineStability.test.js):一道拱形站在兩隻圓腳上,會自己搖很久;把兩端平放在洞穴柱頂上才能消掉這件事,而這也是洞穴地形取代原本開闊地面的原因。

倒圓角解決了「卡住」,製造了「搖晃」。


三十段的複合剛體,在 Matter 裡不會停

板子上的旋鈕我全部轉到底,最後讓它停下來的是我裝在板子外面那個

前面都還在可預期的範圍。下面這個不是。

createPlayerLineBody.js 的 JSDoc 記了一段量測:

A ~30-segment compound body resting on two feet does not converge in Matter: the position solver feeds a little energy back every step, and the line oscillates forever at roughly 3 px per step. Measured across thickness, density, chamfer radius and air friction, no combination of body parameters fixes it without ruining how the line falls.

翻成白話:約 30 段的複合剛體靠在兩個支點上靜置時,位置求解器每一步都餵回一點能量,線會以每步約 3px 永遠震盪下去。橫跨厚度、密度、chamfer 半徑、空氣阻力都量過,沒有一組參數能在不毀掉落下手感的前提下修好它

這是「參數調校有其極限」的具體證據,不是心得:可調的旋鈕全部試過,每一組都在「線停得下來」與「線落下來好看」之間二選一。

解法在引擎之外。三個常數:

const SETTLE_SPEED = 4

/** Speed below which the line is simply parked. */
const SLEEP_SPEED = 0.4

const SETTLE_DAMPING = 0.85

settlePlayerLineBody() 每個固定步跑一次(src/scenes/GameScene.js:239,緊接在 physicsManager.step() 之後):速度高於 4 px/step 就完全不管;介於 0.4 與 4 之間,把線速度與角速度各乘 0.85;低於 0.4,直接歸零。

閾值為什麼選在那裡,JSDoc 也寫了理由:這個函式只碰「已經幾乎靜止」的線,而蜂群真的把線推動時速度遠高於這個門檻——被蜂群推得晃來晃去正是應該看得見的東西,不能一起壓掉。補丁該有的樣子就是這樣:有明確的作用範圍,不是全域壓住所有運動。


最直覺的修法會把遊戲弄壞,而且不報錯

我拉下那根拉桿,畫面什麼都沒變,只有牆上那個數字從十二翻成零

看到「線一直抖」,第一個反應會是:讓它睡著。Matter 有現成的 Sleeping,一行 Sleeping.set(body, true) 就好。

這條路走過,而且有數字。fff7160 的 commit 訊息記著:

對線條強制呼叫 Sleeping.set 讓每一關從 12/12 掉到 0/12:睡著的複合剛體會讓蜜蜂偵測不到撞擊,於是牠們永遠不會進入 HIT 狀態,直接磨穿過去。現在改成阻尼並讓線條停下來,完全不碰它的睡眠狀態。

那個 12 是關卡可解性測試的種子數(tests/unit/levelSolvability.test.jsSEED_COUNT = 12):每一關用 12 個亂數種子各跑一次,全過才算數。

這個數字要講清楚它為什麼可怕:它不是「效果變差」,是遊戲完全不能玩了,而觸發它的只是一個看起來合理的一行修改。更難查的是它不會報錯——線還在、蜜蜂還在動、畫面看起來正常,沒有例外、沒有 console 警告、沒有斷言失敗,只有通關率從 12 變成 0。

沒有那 12 個種子的檢查,我大概會玩兩次覺得怪怪的,然後去查蜜蜂的追蹤參數。

同一個 commit 還記了一個方向相反的案例:圓頂形防線壓在狗狗身上時,兩個動態剛體持續接觸,兩邊都永遠睡不著,實測是無限震盪並把狗狗推向側邊。睡眠這件事在這個專案裡踩了兩次,一次是不該睡的睡了,一次是該睡的睡不著。


交給 AI

這一段我只交出去一半。

交出去的是結構。 createPlayerLineBody() 的合約完整:吃一組點與厚度,吐一個 labelplayer-line 的複合剛體,零件數對應線段數,少於兩點要丟錯。歸檔規格有對應的 Scenario,tests/unit/createPlayerLineBody.test.js 兩個案例照著它寫。可以委派,因為驗收條件在動工前就存在。

沒交出去的是那三個數字。 SETTLE_SPEED = 4SLEEP_SPEED = 0.4SETTLE_DAMPING = 0.85 沒有一個是推導出來的,也沒有一個描述得成規格。判準是「線停下來了,但被蜂群推的時候還看得見在動」——這句話變不成斷言。

但有一個對照值得記下來:那三個閾值不能委派,「加了閾值之後線會不會停」可以。 tests/unit/lineStability.test.js 有一個案例是 settled 之後再跑 10 秒不漂移,斷言為位移小於 2px。手感沒辦法自動化,手感造成的結果可以。


帶走什麼

一句話:

「選比較可預測的那個」是對的建議,但不要以為選完就沒事了。可預測的意思是「壞掉的方式比較好查」,不是「不會壞」。

三件今天就能做的事:

  1. 把被排除的選項寫進規格,連理由一起寫。 一句 MUST NOT 的成本是一行,走進去再退出來的成本是幾天。但要誠實記下你是「推導」還是「量測」得到結論的。
  2. 參數調不動的時候,考慮補在引擎外面。 這條線是靠一個二十幾行的阻尼函式停下來的,不是靠 Matter 的參數。前提是補丁要有明確的作用範圍,不能全域壓住。
  3. 給「不會報錯的壞掉」準備一個數字。 強制睡眠沒丟出任何例外,只有 12/12 → 0/12 抓得到它。你的專案裡有沒有一個數字,能在畫面看起來正常的時候告訴你它壞了?

明天 Day 16 換到防線的另一邊:蜜蜂怎麼追人。結論會有點掃興——不需要 A*,不需要導航網格,不需要行為樹,三個向量加起來就夠了。而那一篇會是明確寫著「這段我沒有交給 AI」的其中一篇,理由跟今天那三個閾值一樣。


本篇數字的快照時間: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 14|幾何抽稀:一千個點怎麼變成四十個
下一篇
Day 16|蜜蜂怎麼追人:三個向量加起來就夠了
系列文
一條線救一隻狗:我用 PixiJS、Matter.js 和一條有閘門的 AI 產線做完一款網頁小遊戲16
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言