iT邦幫忙

2026 iThome 鐵人賽

DAY 13
0

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

昨天把 clientX 換算成了遊戲座標。一個點有了,但一次畫線不是一個點——它是一段有開始、有結束、隨時可能被打斷的過程。

開工前的 PRD.md 為這段過程畫了一張狀態圖,八個狀態。實作寫完,src/systems/DrawingSystem.js 裡只有五個。

結論先講:多出來的那三個,剛好是三種不該變成狀態的東西——輸入事件、驗證、取消。 它們是進出狀態的邊,不是可以停留的點。這篇把五個狀態、兩條退出路徑,還有一個我到現在沒補上的漏洞攤開。


規格畫了八個,程式碼只有五個

八根站牌我拔掉三根,因為那三個地方根本沒有人會停

PRD.md:1104 那張圖是這個序列:

IDLEPOINTER_DOWNDRAWINGPOINTER_UPSIMPLIFYINGVALIDATINGBUILDING_BODYLOCKED。任何階段收到 pointercancellostpointercapture 或 Scene Destroy → CANCELLEDIDLE

實作裡的狀態不是常數物件,是 DrawingSystem 上的一個字串欄位 this.state,全庫只被指派成五個值:

狀態 指派位置 這個階段做什麼
'idle' :72 建構、:117 reset():250 cancel():256 reject() 初始,以及所有回到原點的路徑
'drawing' :152 pointerdown 末尾 只累積點、只重畫預覽,不建立任何物理物件
'simplifying' :215 finalize() 開頭 跑抽稀管線(Day 14)
'building-body' :228 呼叫 bodyFactory 造複合剛體(Day 15)
'locked' :241 之後的 pointerdown:135this.playerLineBody 的守衛擋掉

對照之下,規格多出來的四個是:POINTER_DOWNPOINTER_UPVALIDATINGCANCELLED

前兩個最明顯:它們是事件,不是狀態。 一個 pointerdown 進來,程式做完該做的事就已經在 drawing 了,中間沒有任何一個瞬間值得被觀察。把輸入事件畫進狀態圖,狀態數會跟著輸入方式膨脹——今天多一個手寫筆按鈕,圖上就要多兩個框。

後兩個要分開講。

順帶一提,drawing 這個狀態「不建立任何物理物件」不是我自己的習慣,是歸檔規格裡的驗收條件寫明的(openspec/changes/archive/2026-08-05-add-drawing-system/specs/drawing-system/spec.md):pointer 被取消時「MUST NOT create a Matter body」。畫的當下只有一條 Pixi 的線在動,物理世界完全不知道有人在畫。這條界線讓取消變得便宜——沒有東西需要被回收。


驗證不是狀態,是兩個 if——而且要做兩次

VALIDATING 在實作裡沒有對應的框。最短長度檢查就寫在 finalize() 裡,而且前後各做一次:

  finalize() {
    const length = calculatePolylineLength(this.rawPoints)

    if (length < this.config.minimumLength) {
      this.reject('minimum-length')
      this.clearInput()
      this.drawPreview()
      return null
    }

    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)

    if (calculatePolylineLength(points) < this.config.minimumLength || points.length < 2) {
      this.reject('minimum-length')
      this.clearInput()
      this.drawPreview()
      return null
    }

第二次檢查不是複製貼上的冗餘。抽稀會把轉折拉直,一條原本剛好過關的短鋸齒線,簡化完可能就低於 24px 了。第一次擋的是「玩家根本沒畫」,第二次擋的是「算完之後不夠了」。

而禁畫區的檢查更早,早到根本沒進狀態機——它在 pointerdown 裡(:141-144),不通過就直接 reject('forbidden-zone')returnactivePointerId 連設都沒設。

三個檢查點沒有一個需要停留。「驗證中」不是一段時間,是一個分支。


禁畫區擋的是起筆點,不是整條線

我在入口把票剪得很仔細,進去之後那一整片其實沒有人在管

五個關卡的禁畫區都是同一組形狀(以 src/levels/level01.js:37-41 為例):

區域 型別
狗狗安全圈 circle 圓心在狗身上,半徑 68
蜂巢 circle 圓心 (375, 300),半徑 70
UI 帶 rect (0, 0) 起算 750 × 116,五關共用(src/levels/sharedLevelParts.js:57-63

判定函式在 src/utils/geometry.js:148-167circle 比距離、rect 比四邊。關卡資料自己也被檢查:src/levels/levelRepository.js:210-211 會驗參考解的起點不在禁畫區裡。

這裡有一個我讀自己程式碼才發現的洞:handlePointerMovefinalize() 都沒有再檢查禁畫區。 從安全圈外面起筆之後,手指可以一路畫穿過狗狗頭上,那條線照樣成立。

擋在 pointerdown 的設計本身是對的——畫完才說不行,玩家已經花了一次操作。但我原本以為「禁畫區」擋的是整條線,實際擋的只有第一個點。這件事沒有被任何測試涵蓋,因為測試(tests/unit/DrawingSystem.test.js:156)也只驗了起點。

畫面上這件事還有一層:src/scenes/GameScene.js:462drawForbiddenHints() 只把 circle 型的禁區描出淡淡的框(alpha 0.12),rect 那條 UI 帶完全沒有畫出來。玩家看得到兩個圈,看不到頂端那條 116px 的帶子。


取消做四件事,拒絕只做兩件

CANCELLED 在實作裡也不是框,是一條邊。兩條退出路徑並排看最清楚:

  cancel(reason) {
    this.releaseActivePointer()
    this.clearInput()
    this.state = 'idle'
    this.drawPreview()
    this.onCancel?.({ reason })
  }

  reject(reason) {
    this.state = 'idle'
    this.onReject?.({ reason })
  }

cancel() 做四件事:釋放 pointer capture、清空點陣列與取樣時戳、狀態歸零、把預覽 Graphics 清乾淨。漏掉任何一件都會留下殘留狀態。

reject() 只做兩件。這個不對稱不是偷懶:reject() 的兩個呼叫點都發生在 pointer 已經不在的時候——forbidden-zone 那次根本還沒 capture,minimum-length 那兩次是在 handlePointerUp 釋放之後(:193)才進到 finalize()。清理由呼叫端各自補(:210-211:222-224)。

理由被保留下來了,但沒有為它多開一個狀態。目前有三個取消理由與兩個拒絕理由:

回呼 理由 觸發點
onCancel 'disabled' setEnabled(false) 且正在畫(:109
onCancel 'resize' 視窗尺寸變了(:122
onCancel 'pointer-cancel' pointercancellostpointercapture:202
onReject 'forbidden-zone' 起筆在禁區(:142
onReject 'minimum-length' 抽稀前後任一次太短(:209:222

還有一個容易寫錯的細節:cancel() 不會移除已經鎖定的剛體,reset() 才會。程式碼裡有一行註解說明理由(:106-107)——停用畫線只該丟掉「正在畫的那一筆」,已經落地的防線要留在畫面上。

至於場景銷毀,destroy():125-132)把 attach() 註冊的五個 listener 一一移除,最後再呼叫一次 reset()


五個狀態之外,還有三個藏起來的布林

砍到五個狀態聽起來很乾淨。但我把整個檔案再讀一次,發現狀態沒有變少,只是有些換了個地方住DrawingSystem 上還有三個布林值,每一個都在改變系統的行為:

欄位 設定/清除 它擋掉什麼
enabled :67setEnabled() :103-111 falsepointerdown 直接 return(:135),已鎖定的線留著
activePointerId :147 設、releaseActivePointer() :344-351 null 時第二根手指被忽略(:135);pointermoveupcancel 都先比對它
lengthCapped :183 設、:151clearInput() true 之後 pointermove 直接 return(:164-166),這一筆再也不收點

第三個最有意思。線長一旦碰到關卡上限,lengthCapped 就變成 true,之後手指還在動、pointermove 還在來,但整個處理器第一件事就是 return。狀態欄位仍然是 'drawing',行為卻已經完全不一樣了——這是一個藏在 drawing 裡面的子狀態。

我不打算把它拉出來變成第六個狀態,因為它沒有進出的儀式(不需要清資料、不需要通知任何人),把它變成狀態只會讓 finalize() 要多判斷一次。但它確實推翻了我前面那句話的一半:狀態機不是「系統的全部狀態」,是「你決定要顯式管理的那部分」。 剩下的還是存在,只是散在欄位裡。

判斷標準我現在會這樣寫:進出時需要做事的,畫成狀態;只是改變一個分支走向的,留成布林。 lengthCapped 屬於後者,locked 屬於前者(它要建剛體、要重畫、要發回呼)。

順帶一句:畫線相關真正發生過的 bug 是渲染同步那兩個,Day 10 已經完整講過,這裡不重複。至於「重玩之後畫面留下前一局殘影」——我寫大綱時猜這篇會有這個故事,翻 git log 那個 bug 沒有發生過,所以我不補寫。


這篇還沒被真機檢驗過

pointercancel 在規格上由誰觸發,W3C Pointer Events 寫得清楚:瀏覽器判定成手勢、輸入裝置狀態改變、pointer 被其他方式取走。所以我把它接成正常路徑上的一條邊,而不是包在 try/catch 裡的補救。

但我要誠實標一件事:這個設計還沒在 iOS 實機上驗過。 專案的 dev-log.md 證據索引第 6 項「iOS 畫線被瀏覽器取消的重現條件」到今天還是「⬜ 可補」。我不知道實際上是滑動手勢、來電還是通知列先送出它,也不知道送出之後畫面是不是真的乾淨。上面所有的敘述都是讀程式碼與讀規格得出的,不是玩出來的。


交給 AI

這一段有具體材料。專案根目錄的 AGENTS.md 有一條工程規則:

Event listeners must have matching cleanup logic.

這是一條 AI 讀得懂、而且可以逐字對照的約束。對照結果:attach() 註冊五個(:96-100),destroy() 移除五個(:126-130),一對一。

而且它有測試守著——tests/unit/DrawingSystem.test.js 第一個案例把註冊的事件名排序後整組比對:

    expect([...canvas.listeners.keys()].sort()).toEqual([
      'lostpointercapture',
      'pointercancel',
      'pointerdown',
      'pointermove',
      'pointerup'
    ])

我沒有留下「AI 曾經漏掉某個 removeEventListener」的紀錄,所以不寫它擋下過什麼。可以寫的是這道閘門的形狀:一條散文規則加上一個會失敗的斷言,比十句叮嚀有用——因為前者能在 npm run test:unit 裡變成紅色。


帶走什麼

一句話:

狀態機畫的是「需要停留的階段」,不是「發生過的事」。事件、驗證、取消都不需要停留;把它們畫成框,圖會看起來很專業,然後跟程式碼對不上。

三件今天就能做的事:

  1. 問每個框「進出這裡需不需要做事」。 需要的畫成狀態,只改變分支走向的留成布林。我的八個框砍到五個,同時發現另外三個布林一直都在,只是沒被畫進圖裡。
  2. 把取消路徑列成清單再寫程式。 釋放 capture、清資料、狀態歸零、清畫面——四件事寫成一個函式,不要散在各個事件處理器裡。
  3. 檢查你的守衛擋的是起點還是全程。 我的禁畫區只擋起筆點,這件事讀規格看不出來,讀程式碼才看得到。

明天 Day 14,講 simplifying 那四行裡發生了什麼:pointermove 收到的原始點又多又密,怎麼在四個函式裡變成最多 64 個節點。裡面有一個反直覺的結果——抽稀之後有幾個節點,跟你畫多快、手機回報率多高都沒有關係,只跟線有多長有關,而且這句話可以用 repo 裡的純函式當場量給你看。


本篇數字的快照時間:2026-08-07 12:35(+0800),對應 commit 5aa3705。專案仍在開發中,量體數字會變動;引用的每一項都可以用本文提到的檔案路徑與行號自行對照。
可玩網址https://save-the-dog-web.vercel.app/原始碼https://github.com/HarryFan/save-the-dog-web

參考資料

如果你卡在語法

深入原理


上一篇
Day 12|座標轉換:手指在螢幕上的位置,不是它在遊戲裡的位置
下一篇
Day 14|幾何抽稀:一千個點怎麼變成四十個
系列文
一條線救一隻狗:我用 PixiJS、Matter.js 和一條有閘門的 AI 產線做完一款網頁小遊戲16
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言