模組三|畫線:從手指到剛體(Day 10–15)
昨天的狀態機裡有一個叫 simplifying 的階段,它的內容是四行函式呼叫。今天把那四行拆開。
pointermove 會送多少點,取決於裝置的回報率和手指移動速度,這件事在寫程式的時候是不可控的。可控的是:每個點最後會變成一個 Matter.js 的矩形剛體,所以這一層決定的是物理引擎要扛多少東西。
結論先講:抽稀之後有幾個節點,跟你畫多快、手機回報率多高都沒有關係,只跟線有多長有關。 這句話可以用 repo 裡的純函式當場量出來,這篇最後會量。
原始點還沒進管線之前,handlePointerMove 先擋一次(src/systems/DrawingSystem.js:170-176)。條件長這樣:
movedEnough:距離上一個點 ≥ sampleMinimumDistance(3px)waitedEnough:距離上一次取樣 ≥ sampleMaximumIntervalMs(24ms,五關都沒覆寫,用的是 DEFAULT_DRAW_CONFIG 的預設值)兩者取或,兩個都不成立才 return。
這兩道門檻的目的相反,放在同一個 if 裡:距離門檻是為了丟掉太密的點(手指幾乎不動時,每個 pointermove 都記一次等於在原地堆點);時間門檻是為了保證慢速畫線也留得下點(一格一格慢慢挪,光靠距離門檻可能長時間一個點都不記)。
大綱裡我只寫了距離門檻。讀程式碼才發現時間門檻是必要的另一半——只有前者的話,慢速輸入會變成一條由極少數點構成的線;只有後者的話,快速輸入會塞爆陣列。
放手之後(finalize())才跑真正的抽稀。src/systems/DrawingSystem.js:215-219:
this.state = 'simplifying'
let points = clipPolylineToLength(this.rawPoints, this.config.maximumLength)
points = simplifyRdp(points, this.config.simplifyEpsilon)
points = resamplePolyline(points, this.config.resampleSpacing)
points = limitPolylinePoints(points, this.config.maximumPoints)
四行函式呼叫,加上取樣門檻共五步。每一步解決的問題不同:
| 步驟 | 函式 | 參數(實際值) | 解決什麼 |
|---|---|---|---|
| 取樣門檻 | 寫在事件處理器裡 | 3px 或 24ms | 原始點過密/過疏 |
| 長度裁切 | clipPolylineToLength |
每關不同:500/460/430/400/550 | 遊戲規則:一條線的預算 |
| RDP 簡化 | simplifyRdp |
epsilon 2.5 |
近似共線的冗餘點 |
| 等距重採樣 | resamplePolyline |
間距 12 |
讓每段物理線段一樣長 |
| 節點上限 | limitPolylinePoints |
64 |
物理物件數的硬天花板 |
長度裁切這一步大綱漏了,而它是唯一每一關都不同的參數(src/levels/level01.js:36 起)。它不是效能設定,是關卡難度設計:第四關只給 400px,第五關給 550px。
其餘四個參數五關共用,定義在 src/levels/sharedLevelParts.js:45-54 的 SHARED_DRAW_TUNING,合併進 DrawingSystem 的預設值(:53 的 { ...DEFAULT_DRAW_CONFIG, ...drawConfig })。
節點上限 64 的意義要換算才看得出來:bodyPartCount 是 parts.length - 1(:90),所以 64 個節點最多產出 63 個矩形剛體。這個上限有測試守著——tests/unit/determinism.test.js 有一個案例叫 keeps the Matter body count inside the performance budget,它用「畫線規則允許的最大節點數」造一條最壞情況的線來驗。

Ramer–Douglas–Peucker 的白話版:找出離「頭尾連線」最遠的那個點;如果它的距離小於閾值,代表這一整段可以用一條直線代替,中間的點全丟;否則從那個點切開,兩半各自遞迴。
入口函式(src/utils/geometry.js:75-87):
export function simplifyRdp(points, epsilon) {
if (!Array.isArray(points) || points.length <= 2 || epsilon <= 0) {
return points?.slice() ?? []
}
const keep = new Array(points.length).fill(false)
keep[0] = true
keep[points.length - 1] = true
simplifySegment(points, 0, points.length - 1, epsilon, keep)
return points.filter((_, index) => keep[index])
}
這裡有一個值得抄的實作選擇:它不切陣列、不合併結果,只在一個布林陣列上標記要留哪些索引。 遞迴結束之後一次 filter。教科書寫法通常是兩半各自回傳陣列再 concat,那會在遞迴過程中一直配置新陣列。
遞迴本體(:169-193):
function simplifySegment(points, startIndex, endIndex, epsilon, keep) {
let maxDistance = 0
let maxIndex = startIndex
for (let index = startIndex + 1; index < endIndex; index += 1) {
const currentDistance = perpendicularDistance(
points[index],
points[startIndex],
points[endIndex]
)
if (currentDistance > maxDistance) {
maxDistance = currentDistance
maxIndex = index
}
}
if (maxDistance <= epsilon) {
return
}
keep[maxIndex] = true
simplifySegment(points, startIndex, maxIndex, epsilon, keep)
simplifySegment(points, maxIndex, endIndex, epsilon, keep)
}
行數要誠實講:入口 13 行、遞迴 25 行、點到線距離 14 行(:195-208),整組 52 行。我在大綱裡寫「十五行就寫得完」,那只算了入口。
RDP 保留的是轉折點,所以剩下的線段長短差很多——一段可能 3px,下一段 200px。物理上這是問題:複合剛體的每個矩形零件都有自己的質量,長短差幾十倍的零件湊在一起,質量分佈會集中在幾個大塊上。
resamplePolyline(:89-126)沿著簡化後的路徑,每隔 12px 插一個點,末端若距離最後一個點超過 0.5px 就補上終點(:121)。
順序不能換。 先重採樣再簡化的話,重採樣製造出來的中間點會被 RDP 判定為共線而全部刪掉,白做工。
大綱裡我寫超過上限時「用更大的間距重新採樣一次」。實作不是這樣(:128-146):limitPolylinePoints 對已經存在的點陣列做等索引取樣——Math.round((index / (maxPoints - 1)) * lastIndex),頭尾必留。
結論是對的(線不會被截斷),機制描述是錯的。差別在:等索引挑點之後,點與點之間不再嚴格等距,因為原本 12px 的間隔被抽掉幾個之後就變成 24px、36px 的混合。這是為了保住上限而放棄等距的取捨,前一步剛建立起來的性質在這裡被部分放棄。
好消息是這一步很少真的生效,下一節有量測。

以下是我用 repo 裡的 geometry.js 直接跑出來的【實測】。輸入是我寫的合成筆畫,不是真人畫的——真人資料我沒有留,所以這張表證明的是「管線的行為」,不是「玩家的手長什麼樣」。參數用第一關的值(長度上限 500、epsilon 2.5、間距 12、上限 64)。
| 合成輸入 | 原始點 | 裁長度後 | RDP 後 | 重採樣後 | 限點數後 | 最終線段 |
|---|---|---|---|---|---|---|
| 400px 直線,取樣 1000 點 | 1000 | 1000 | 2 | 35 | 35 | 34 |
| 480px 弧線,取樣 160 點 | 160 | 151 | 5 | 40 | 40 | 39 |
| 470px 弧線+±4px 抖動 | 160 | 86 | 71 | 43 | 43 | 42 |
| 鋸齒線,取樣 800 點 | 800 | 33 | 30 | 40 | 40 | 39 |
| 480px 弧線慢畫,取樣 1000 點 | 1000 | 535 | 4 | 23 | 23 | 22 |
三個可以直接讀出來的結果:
第三列還有一個附帶觀察:同樣是 470px 左右的弧,加上抖動之後原始路徑長度膨脹到 900 多,於是長度預算被抖動吃掉,裁切在第 86 個點就中斷了。抖動幅度是我設的,數字不能外推,但方向是確定的:長度預算算的是路徑長,不是兩端距離。
src/utils/geometry.js 208 行,tests/unit/geometry.test.js 115 行、6 個測試案例。兩個檔案都在 ebf8acb(2026-08-06 04:58:59)一起進來,到快照當下為止,git log 對這兩個檔案各只有一筆紀錄。
同一個 commit 進來的 DrawingSystem.js 被改過兩次,createPlayerLineBody.js 被改過兩次。抽稀這一層沒有。
我不會把這說成「因為我寫得好」——更可能的解釋是它的介面太窄:geometry.js 全檔零 import(Day 5 講過這件事),每個函式都是「吃陣列、吐陣列」,沒有時間、沒有畫布、沒有引擎。沒有依賴的東西,沒有東西可以拖著它一起變。
這是全系列最適合交給 AI 的一種程式碼,理由不是「演算法很常見」,是它的驗收條件可以先寫出來。
simplifyRdp 的合約是完整的:給定一組點與一個 epsilon,輸出一組點,頭尾必留,其餘的判準是點到線距離。這種東西不需要我用自然語言描述「大概要簡化一下」——我可以先寫下期望的輸入輸出,再讓它去填中間。
geometry.test.js 那六個案例就是這份合約:距離與折線長度、座標轉換、長度裁切、簡化與重採樣、限點數且保留頭尾、禁畫區判定。而 AGENTS.md 規定收工前要跑 npm run test:unit -- --run——閘門是紅綠燈,不是我逐行讀。
要說得準確一點:我沒有留下「AI 在這個檔案上被退回過幾次」的紀錄,所以不寫它撞過幾次牆。可以寫的是這個模式的形狀——先有可執行的判準,才有值得委派的任務。反過來的例子在後面:Day 16 的蜜蜂參數沒有判準可寫,那些數字只能靠玩,所以我沒有交出去。
一句話:
抽稀不是效能優化,是把「玩家的手」翻譯成「物理引擎的輸入」。輸入沒翻譯乾淨,後面每一層都在處理噪音。
三件今天就能做的事:
明天 Day 15,講這些點怎麼變成 Matter.js 裡的東西。規格在寫程式之前就用一句 MUST NOT 關掉了「柔性約束鏈」這條路,我一行都沒寫過;但選了看起來比較保守的單一複合剛體之後,在那條路上又踩到另一種不穩定——一個約 30 段的複合剛體靠在兩個支點上時,在 Matter 裡不會停下來,而最直覺的修法會讓遊戲的通關率從每關 12/12 掉到 0/12,且不會報任何錯。
本篇數字的快照時間:2026-08-07 12:35(+0800),對應 commit
5aa3705。專案仍在開發中,量體數字會變動;管線的量測表可以用本文提到的檔案路徑自行重跑。
可玩網址:https://save-the-dog-web.vercel.app/|原始碼:https://github.com/HarryFan/save-the-dog-web
如果你卡在語法
深入原理