iT邦幫忙

2026 iThome 鐵人賽

DAY 14
0

模組三|畫線:從手指到剛體(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-54SHARED_DRAW_TUNING,合併進 DrawingSystem 的預設值(:53{ ...DEFAULT_DRAW_CONFIG, ...drawConfig })。

節點上限 64 的意義要換算才看得出來:bodyPartCountparts.length - 1:90),所以 64 個節點最多產出 63 個矩形剛體。這個上限有測試守著——tests/unit/determinism.test.js 有一個案例叫 keeps the Matter body count inside the performance budget,它用「畫線規則允許的最大節點數」造一條最壞情況的線來驗。


RDP:名字很嚇人,主體十來行

我把板子壓下去,貼著它的那些自己就沒了,只有頂著它的留下來

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

三個可以直接讀出來的結果:

  1. RDP 的輸出跟輸入點數幾乎無關。 直線 1000 點進去,出來 2 個點。它砍掉的是「共線」,不是「數量」。
  2. 最終節點數由重採樣間距決定。 第一列 35 個點,約等於 400px 除以 12(另外補上起點與終點);第二列 40 個點,約等於簡化後路徑長度除以 12。標題那個「四十個」是這樣來的,不是固定值。
  3. 64 的上限在這五種輸入裡一次都沒有生效。 要撞到它,簡化後的路徑得超過 756px,而每一關的長度預算最多只有 550px——上限是一道幾乎不會被觸發的保險,不是日常路徑。

第三列還有一個附帶觀察:同樣是 470px 左右的弧,加上抖動之後原始路徑長度膨脹到 900 多,於是長度預算被抖動吃掉,裁切在第 86 個點就中斷了。抖動幅度是我設的,數字不能外推,但方向是確定的:長度預算算的是路徑長,不是兩端距離。


這個檔案從進 repo 那天起沒有被改過

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

這是全系列最適合交給 AI 的一種程式碼,理由不是「演算法很常見」,是它的驗收條件可以先寫出來

simplifyRdp 的合約是完整的:給定一組點與一個 epsilon,輸出一組點,頭尾必留,其餘的判準是點到線距離。這種東西不需要我用自然語言描述「大概要簡化一下」——我可以先寫下期望的輸入輸出,再讓它去填中間。

geometry.test.js 那六個案例就是這份合約:距離與折線長度、座標轉換、長度裁切、簡化與重採樣、限點數且保留頭尾、禁畫區判定。而 AGENTS.md 規定收工前要跑 npm run test:unit -- --run——閘門是紅綠燈,不是我逐行讀。

要說得準確一點:我沒有留下「AI 在這個檔案上被退回過幾次」的紀錄,所以不寫它撞過幾次牆。可以寫的是這個模式的形狀——先有可執行的判準,才有值得委派的任務。反過來的例子在後面:Day 16 的蜜蜂參數沒有判準可寫,那些數字只能靠玩,所以我沒有交出去。


帶走什麼

一句話:

抽稀不是效能優化,是把「玩家的手」翻譯成「物理引擎的輸入」。輸入沒翻譯乾淨,後面每一層都在處理噪音。

三件今天就能做的事:

  1. 兩道門檻要成對出現。 只有距離門檻的取樣器,在慢速輸入下會漏掉整段軌跡;只有時間門檻的,在快速輸入下會堆滿重複點。
  2. 把處理管線寫成一串純函式,順序寫在同一個地方。 上面那四行看得出順序、看得出參數來源,也因此可以整段搬去單元測試裡跑。
  3. 量一次你的上限有沒有被觸發。 我的 64 節點上限在五種合成輸入裡一次都沒生效——它是保險,我原本以為它是主力。

明天 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

參考資料

如果你卡在語法

深入原理


上一篇
Day 13|畫線狀態機:`pointercancel` 是常態,不是例外
下一篇
Day 15|為了躲開一種不穩定,換到了另一種不穩定
系列文
一條線救一隻狗:我用 PixiJS、Matter.js 和一條有閘門的 AI 產線做完一款網頁小遊戲16
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言