模組三|畫線:從手指到剛體(Day 10–15)
昨天把 clientX 換算成了遊戲座標。一個點有了,但一次畫線不是一個點——它是一段有開始、有結束、隨時可能被打斷的過程。
開工前的 PRD.md 為這段過程畫了一張狀態圖,八個狀態。實作寫完,src/systems/DrawingSystem.js 裡只有五個。
結論先講:多出來的那三個,剛好是三種不該變成狀態的東西——輸入事件、驗證、取消。 它們是進出狀態的邊,不是可以停留的點。這篇把五個狀態、兩條退出路徑,還有一個我到現在沒補上的漏洞攤開。

PRD.md:1104 那張圖是這個序列:
IDLE→POINTER_DOWN→DRAWING→POINTER_UP→SIMPLIFYING→VALIDATING→BUILDING_BODY→LOCKED。任何階段收到pointercancel、lostpointercapture或 Scene Destroy →CANCELLED→IDLE。
實作裡的狀態不是常數物件,是 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 被 :135 對 this.playerLineBody 的守衛擋掉 |
對照之下,規格多出來的四個是:POINTER_DOWN、POINTER_UP、VALIDATING、CANCELLED。
前兩個最明顯:它們是事件,不是狀態。 一個 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') 並 return,activePointerId 連設都沒設。
三個檢查點沒有一個需要停留。「驗證中」不是一段時間,是一個分支。

五個關卡的禁畫區都是同一組形狀(以 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-167,circle 比距離、rect 比四邊。關卡資料自己也被檢查:src/levels/levelRepository.js:210-211 會驗參考解的起點不在禁畫區裡。
這裡有一個我讀自己程式碼才發現的洞:handlePointerMove 與 finalize() 都沒有再檢查禁畫區。 從安全圈外面起筆之後,手指可以一路畫穿過狗狗頭上,那條線照樣成立。
擋在 pointerdown 的設計本身是對的——畫完才說不行,玩家已經花了一次操作。但我原本以為「禁畫區」擋的是整條線,實際擋的只有第一個點。這件事沒有被任何測試涵蓋,因為測試(tests/unit/DrawingSystem.test.js:156)也只驗了起點。
畫面上這件事還有一層:src/scenes/GameScene.js:462 的 drawForbiddenHints() 只把 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' |
pointercancel 或 lostpointercapture(:202) |
onReject |
'forbidden-zone' |
起筆在禁區(:142) |
onReject |
'minimum-length' |
抽稀前後任一次太短(:209、:222) |
還有一個容易寫錯的細節:cancel() 不會移除已經鎖定的剛體,reset() 才會。程式碼裡有一行註解說明理由(:106-107)——停用畫線只該丟掉「正在畫的那一筆」,已經落地的防線要留在畫面上。
至於場景銷毀,destroy()(:125-132)把 attach() 註冊的五個 listener 一一移除,最後再呼叫一次 reset()。
砍到五個狀態聽起來很乾淨。但我把整個檔案再讀一次,發現狀態沒有變少,只是有些換了個地方住。DrawingSystem 上還有三個布林值,每一個都在改變系統的行為:
| 欄位 | 設定/清除 | 它擋掉什麼 |
|---|---|---|
enabled |
:67、setEnabled() :103-111 |
false 時 pointerdown 直接 return(:135),已鎖定的線留著 |
activePointerId |
:147 設、releaseActivePointer() :344-351 清 |
非 null 時第二根手指被忽略(:135);pointermove/up/cancel 都先比對它 |
lengthCapped |
:183 設、:151 與 clearInput() 清 |
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 畫線被瀏覽器取消的重現條件」到今天還是「⬜ 可補」。我不知道實際上是滑動手勢、來電還是通知列先送出它,也不知道送出之後畫面是不是真的乾淨。上面所有的敘述都是讀程式碼與讀規格得出的,不是玩出來的。
這一段有具體材料。專案根目錄的 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 裡變成紅色。
一句話:
狀態機畫的是「需要停留的階段」,不是「發生過的事」。事件、驗證、取消都不需要停留;把它們畫成框,圖會看起來很專業,然後跟程式碼對不上。
三件今天就能做的事:
明天 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
如果你卡在語法
深入原理