模組三|畫線:從手指到剛體(Day 10–15)
昨天把時間講完了:一個迴圈、兩個時鐘、一個累加器。今天回到迴圈外面——玩家的手指怎麼進到這個系統裡。
這個遊戲的核心操作只有一個動作:按下、拖、放開。三個步驟,聽起來不需要一整篇。但這一層是 PRD.md:9 自己列的三大風險之一(原句是「最高風險不是蜜蜂追蹤,而是畫線物理穩定度、行動裝置 Pointer 操作與 SVG 風格一致性」),而它到今天還是這個專案裡我最沒把握的一層。
結論先講:Pointer Events 讓你不用寫兩套輸入邏輯,但它換來的不是「不用管觸控」,是「你必須主動關掉瀏覽器的手勢」。這個專案把該關的都關了,而且關了兩次——但我到寫這篇為止,還沒有在任何一台真的手機上跑過它。
舊做法是滑鼠一套、觸控一套:mousedown/mousemove/mouseup 配 touchstart/touchmove/touchend。痛點有三個,而且會互相加乘:
| 問題 | 具體長什麼樣 |
|---|---|
| 座標欄位不同 | 滑鼠事件的 clientX 直接掛在 event 上;觸控事件要從 event.touches[0] 裡挖 |
| 觸發順序不同 | 觸控裝置在 touchend 之後還會補送一組模擬的滑鼠事件 |
| 兩套會互相干擾 | 同一次操作跑進兩條路徑,同一段邏輯執行兩次 |
Pointer Events 把三種輸入來源收進同一套事件模型,每個事件帶 pointerId(誰)與 pointerType(滑鼠、觸控、還是觸控筆)。這個專案從第一行就是這樣寫的——PRD.md:42 的技術選型表裡,「輸入」那一列寫的就是「原生 Pointer Events、setPointerCapture()、touch-action: none」,三樣一起列。

實際註冊的事件有五個,不是四個。把註冊與移除並排看(src/systems/DrawingSystem.js:94-101 與 :125-132,同一個檔案的兩處):
attach() {
this.canvas.style.touchAction = 'none'
this.canvas.addEventListener('pointerdown', this.boundPointerDown)
this.canvas.addEventListener('pointermove', this.boundPointerMove)
this.canvas.addEventListener('pointerup', this.boundPointerUp)
this.canvas.addEventListener('pointercancel', this.boundPointerCancel)
this.canvas.addEventListener('lostpointercapture', this.boundPointerCancel)
}
destroy() {
this.canvas.removeEventListener('pointerdown', this.boundPointerDown)
this.canvas.removeEventListener('pointermove', this.boundPointerMove)
this.canvas.removeEventListener('pointerup', this.boundPointerUp)
this.canvas.removeEventListener('pointercancel', this.boundPointerCancel)
this.canvas.removeEventListener('lostpointercapture', this.boundPointerCancel)
this.reset()
}
八行對八行。removeEventListener 要能配對,第二個參數必須跟註冊時是同一個函式參照——這就是建構子裡那四個 boundXxx = this.handleXxx.bind(this) 存在的唯一理由(:76-79)。寫成 addEventListener('pointerdown', (e) => this.handlePointerDown(e)) 也會動,但那個箭頭函式再也拿不回來,這五行移除就全部失效。
最後兩行值得單獨看:lostpointercapture 跟 pointercancel 綁的是同一個 handler(都是 boundPointerCancel)。這不是偷懶。對這個系統來說,「使用者的手勢被中斷」跟「我對這個指標的捕捉被系統收走」是同一件事——畫到一半、後續事件不會再來了、預覽線要清掉。兩個事件名,一個結果。
touch-action: none 在這裡設了兩次規格上,這個 CSS 屬性決定瀏覽器要不要把畫布上的滑動解讀成捲動或縮放手勢。不設的話,瀏覽器可能在你畫到一半的時候接管手勢,並且送出 pointercancel。這是 MDN 與 W3C 規格的描述,也是 PRD.md:61 引用文件後寫下的判斷——不是這個專案量出來的。
這個專案設了兩次:
| 位置 | 寫法 | 作用範圍 |
|---|---|---|
src/style.css:30 |
canvas { touch-action: none; } |
頁面上所有 canvas |
src/systems/DrawingSystem.js:95 |
this.canvas.style.touchAction = 'none' |
attach() 當下這一個 canvas |
我事後才發現有兩處。這算重複,但不算沒意義:CSS 那條是「這個頁面的 canvas 一律不吃手勢」,JavaScript 那條是「DrawingSystem 不假設頁面幫它設好」。第二條讓這個類別可以被丟到任何一個 canvas 上而不必附帶樣式表——測試就是這樣用的(tests/unit/DrawingSystem.test.js 裡的 FakeCanvas 只有一個空的 style 物件,沒有任何 CSS)。
那個測試順便把五個 listener 一併釘死了(tests/unit/DrawingSystem.test.js:115-126):
it('attaches pointer listeners and sets touch action', () => {
const { canvas } = createSystem()
expect(canvas.style.touchAction).toBe('none')
expect([...canvas.listeners.keys()].sort()).toEqual([
'lostpointercapture',
'pointercancel',
'pointerdown',
'pointermove',
'pointerup'
])
})
少註冊一個、多註冊一個,這個測試都會紅。它測不到的是這五個事件在真實瀏覽器裡什麼時候送達——FakeCanvas 是我寫的,它想送什麼就送什麼。
pointerdown 是整條路徑的入口,二十二行裡塞了四件事(src/systems/DrawingSystem.js:134-155):
handlePointerDown(event) {
if (!this.enabled || this.activePointerId !== null || this.playerLineBody) {
return
}
const point = this.eventToGamePoint(event)
if (pointInForbiddenZones(point, this.config.forbiddenZones)) {
this.reject('forbidden-zone')
return
}
event.preventDefault?.()
this.activePointerId = event.pointerId
this.rawPoints = [point]
this.finalPoints = []
this.lastSampleAt = event.timeStamp ?? 0
this.lengthCapped = false
this.state = 'drawing'
this.canvas.setPointerCapture?.(event.pointerId)
this.drawPreview(this.rawPoints)
}
第一行的三個條件,各擋一種情況:
| 條件 | 擋掉的情況 |
|---|---|
!this.enabled |
場景已經把畫線關掉(例如這一關規定線鎖定後不能重畫) |
this.activePointerId !== null |
已經有一根手指在畫了,第二根直接忽略 |
this.playerLineBody |
這一關的線已經鎖定成剛體,不接受新的 |
中間那個就是多點觸控的處理方式:這款遊戲一次只畫一條線,所以第二個 pointerId 沒有任何路徑可以走。 不是「合併兩根手指」,也不是「取消重來」,就是不理它。後續的 pointermove、pointerup、pointercancel 全部在第一行比對 pointerId(:158、:188、:198),對不上就 return。
有測試守著(tests/unit/DrawingSystem.test.js:143,ignores secondary pointers while drawing):先按下 1 號指標,中間夾一整組 2 號指標的 down/move/up,最後 1 號 move 再 up。斷言只有一個剛體被建立。
setPointerCapture(pointerId) 的作用是讓後續屬於這個指標的事件,繼續送給你指定的元素,直到主動釋放或 pointerup。手指滑出畫布邊界的時候,這件事就有差。
在這個專案裡:
| 動作 | 位置 |
|---|---|
| 取得 capture | src/systems/DrawingSystem.js:153,pointerdown 的倒數第二行 |
| 釋放 capture | src/systems/DrawingSystem.js:349,包在 releaseActivePointer() 裡 |
| 誰呼叫釋放 | handlePointerUp()(:193)、cancel()(:248)、reset()(:114) |
兩個呼叫都帶了可選鏈(setPointerCapture?.()、releasePointerCapture?.()),因為 canvas 是注入的,測試裡的假 canvas 不保證有這兩個方法。
這裡要標一件事:我沒有任何紀錄顯示 setPointerCapture 是為了修某個 bug 才加進來的。 它在 PRD.md:42 的選型表裡就出現了,實作只是照著寫。所以我不會說「我遇到手指滑出畫布事件斷掉,加了 capture 才解決」——那是編的。我能說的是:規格文件描述了這個行為,我照著設了,而且釋放路徑跟取得路徑對稱。

大綱原本給這篇的心法是:「輸入層的坑幾乎全部只在真機上出現。桌機模擬觸控能測到座標,測不到瀏覽器的手勢仲裁。」
這句話我維持不變,但它後面必須接一句:我到寫這篇為止,還沒在任何一台真的手機上跑過這個遊戲。 tasks.md 的 4.3(iOS 實機:實測畫線行為、pointercancel 觸發條件、音訊解鎖)到今天還是未勾選。
所以這篇能寫跟不能寫的東西,界線很清楚:
| 可以寫(實查) | 不可以寫 |
|---|---|
| 五個 listener 全註冊、全移除 | 「玩家在畫布上滑動時瀏覽器會判定成捲動」——這是規格描述,不是我量到的 |
touch-action: none 設在兩處 |
「不設的話畫線會被下拉重整打斷」——沒試過,不知道 iOS 上是哪個手勢先搶 |
只吃第一個 pointerId |
「系統手勢、來電、切換 App 都會觸發 pointercancel」——規格上會,我沒有逐項驗過 |
lostpointercapture 與 pointercancel 共用 handler |
「加了 capture 才解決了某個 bug」——沒有紀錄 |
右邊那一欄裡,第二、三項在很多文章裡會被寫成經驗談。它們是規格描述,可以引用,但引用的時候要說清楚它是規格不是實測——這是兩種不同的東西,混在一起讀者就沒辦法判斷你到底試過沒有。
pointercancel 之後要怎麼收拾,明天講。今天只講到事件模型本身。
這一段我幾乎沒有交給 AI 做決定,因為決定在寫程式之前就做完了。
PRD.md:1123-1134 那一節叫「Pointer 事件規格」,內容是一段完整的 JavaScript:canvas.style.touchAction = 'none' 加五個 addEventListener,包括 lostpointercapture 綁到 onPointerCancel 這件事。:1161-1167 又把 setPointerCapture(event.pointerId) 該放在哪裡寫死了。實作出來的 attach() 跟那段規格逐行對得上——這一層不是 AI 設計的,是規格抄過去的,AI 做的是抄寫。
這是 Day 2 那個論點的另一面。規格寫得夠具體,好處是產出可以被逐行對照;代價是這一層再也沒有人會去想「有沒有更好的做法」,包括我自己。我到寫這篇為止都沒有重新檢查過那五個事件是不是最好的組合——我只檢查了它有沒有照抄。
一句話:
Pointer Events 統一的是「事件從哪裡來」,沒有統一「瀏覽器要不要跟你搶這個手勢」。後面那件事必須你主動宣告。
三件今天就能做的事:
addEventListener 的第二個參數存起來。 建構子裡 bind 一次,attach 用它、destroy 用它。用箭頭函式當場包一層,等於宣告這個 listener 永遠不會被移除。touch-action: none 設在你自己的模組裡,不要只靠樣式表。 樣式表是別人的檔案,模組要能自帶前提。pointercancel。明天 Day 12,講 event.clientX 拿到的那個數字。畫布在螢幕上可能是任何大小、可能有 letterbox 留邊、可能在 Retina 上有兩倍的像素緩衝區,而遊戲的邏輯世界永遠是 750×1334。這個轉換在這個專案裡只有十四行、零個 import、一個帶具體數字的單元測試——而三個常見的坑裡,有兩個是被「選對量測基準」自動消掉的,不是被程式碼躲掉的。
本篇數字的快照時間:2026-08-07 12:35(+0800),對應 commit
5aa3705。專案仍在開發中,量體數字會變動;引用的每一項都可以用本文提到的檔案路徑自行對照。
可玩網址:https://save-the-dog-web.vercel.app/|原始碼:https://github.com/HarryFan/save-the-dog-web
如果你卡在語法
深入原理