iT邦幫忙

2026 iThome 鐵人賽

DAY 11
0

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

昨天把時間講完了:一個迴圈、兩個時鐘、一個累加器。今天回到迴圈外面——玩家的手指怎麼進到這個系統裡。

這個遊戲的核心操作只有一個動作:按下、拖、放開。三個步驟,聽起來不需要一整篇。但這一層是 PRD.md:9 自己列的三大風險之一(原句是「最高風險不是蜜蜂追蹤,而是畫線物理穩定度、行動裝置 Pointer 操作與 SVG 風格一致性」),而它到今天還是這個專案裡我最沒把握的一層。

結論先講:Pointer Events 讓你不用寫兩套輸入邏輯,但它換來的不是「不用管觸控」,是「你必須主動關掉瀏覽器的手勢」。這個專案把該關的都關了,而且關了兩次——但我到寫這篇為止,還沒有在任何一台真的手機上跑過它。


為什麼不寫兩套

舊做法是滑鼠一套、觸控一套:mousedownmousemovemouseuptouchstarttouchmovetouchend。痛點有三個,而且會互相加乘:

問題 具體長什麼樣
座標欄位不同 滑鼠事件的 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)) 也會動,但那個箭頭函式再也拿不回來,這五行移除就全部失效。

最後兩行值得單獨看:lostpointercapturepointercancel 綁的是同一個 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 沒有任何路徑可以走。 不是「合併兩根手指」,也不是「取消重來」,就是不理它。後續的 pointermovepointeruppointercancel 全部在第一行比對 pointerId:158:188:198),對不上就 return。

有測試守著(tests/unit/DrawingSystem.test.js:143ignores secondary pointers while drawing):先按下 1 號指標,中間夾一整組 2 號指標的 down/move/up,最後 1 號 move 再 up。斷言只有一個剛體被建立。


Pointer Capture:規格說它做什麼,我沒有它修過 bug 的紀錄

setPointerCapture(pointerId) 的作用是讓後續屬於這個指標的事件,繼續送給你指定的元素,直到主動釋放或 pointerup。手指滑出畫布邊界的時候,這件事就有差。

在這個專案裡:

動作 位置
取得 capture src/systems/DrawingSystem.js:153pointerdown 的倒數第二行
釋放 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」——規格上會,我沒有逐項驗過
lostpointercapturepointercancel 共用 handler 「加了 capture 才解決了某個 bug」——沒有紀錄

右邊那一欄裡,第二、三項在很多文章裡會被寫成經驗談。它們是規格描述,可以引用,但引用的時候要說清楚它是規格不是實測——這是兩種不同的東西,混在一起讀者就沒辦法判斷你到底試過沒有。

pointercancel 之後要怎麼收拾,明天講。今天只講到事件模型本身。


交給 AI

這一段我幾乎沒有交給 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 統一的是「事件從哪裡來」,沒有統一「瀏覽器要不要跟你搶這個手勢」。後面那件事必須你主動宣告。

三件今天就能做的事:

  1. addEventListener 的第二個參數存起來。 建構子裡 bind 一次,attach 用它、destroy 用它。用箭頭函式當場包一層,等於宣告這個 listener 永遠不會被移除。
  2. touch-action: none 設在你自己的模組裡,不要只靠樣式表。 樣式表是別人的檔案,模組要能自帶前提。
  3. 寫一個斷言「註冊了哪些事件」的測試。 它測不到瀏覽器行為,但它擋得住有人在重構時默默拔掉 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

參考資料

如果你卡在語法

深入原理


上一篇
Day 10|遊戲迴圈:誰來驅動誰,以及死亡螺旋
下一篇
Day 12|座標轉換:手指在螢幕上的位置,不是它在遊戲裡的位置
系列文
一條線救一隻狗:我用 PixiJS、Matter.js 和一條有閘門的 AI 產線做完一款網頁小遊戲17
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言