模組三|畫線:從手指到剛體(Day 10–15)
昨天把五個 pointer 事件接上了。事件進來之後,第一件事是把 event.clientX 那個數字翻譯成遊戲裡的座標——因為它們不是同一個東西。
Day 5 講目錄結構的時候埋了一句「先決定座標系,再寫任何一行」。這篇是那句話兌現的地方。
結論先講:這個轉換在這個專案裡是十四行的純函式,沒有 import,沒有分支。三個常見的坑裡有兩個不是被程式碼躲掉的,是被「選對量測基準」自動消掉的,剩下那一個被推到完全不同的一層去處理。
遊戲的邏輯世界永遠是 750×1334(src/config/game.js:1-2)。所有關卡資料、物理座標、禁畫區、蜜蜂位置都用這個尺度寫死。
但 canvas 在螢幕上是任何大小。它由 computeContainViewport() 算出來(src/core/viewport.js:26),規則是取 Math.min(viewportWidth / worldWidth, viewportHeight / worldHeight)——寬高哪一邊先滿就用哪一邊當縮放比,另一邊留白置中。單元測試把三種情況都釘住了(tests/unit/viewport.test.js):直立手機以寬度貼齊、寬螢幕桌機以高度貼齊、剛好 750×1334 時縮放比是 1。
留白怎麼落到畫面上,只有五行(src/core/viewport.js:41-45):
export function applyCanvasViewport(canvas, viewport) {
canvas.style.width = `${viewport.displayWidth}px`
canvas.style.height = `${viewport.displayHeight}px`
canvas.style.transform = `translate(${viewport.offsetX}px, ${viewport.offsetY}px)`
}
CSS 尺寸加一個 translate。記住最後這一行,等一下要用。
這裡順帶交代一個容易被當成必備品的東西:index.html:6-9 的 viewport meta 只寫了 width=device-width, initial-scale=1.0, viewport-fit=cover,沒有 user-scalable=no,也沒有 maximum-scale。禁止使用者縮放這件事,這個專案沒有做——手勢是靠昨天講的 touch-action: none 擋在畫布上,不是靠關掉整個頁面的縮放能力。
轉換不在 viewport.js 裡。它在 src/utils/geometry.js:26-39:
export function clientToGamePoint({
clientX,
clientY,
canvas,
gameWidth,
gameHeight
}) {
const rect = canvas.getBoundingClientRect()
return {
x: ((clientX - rect.left) / rect.width) * gameWidth,
y: ((clientY - rect.top) / rect.height) * gameHeight
}
}
三個動作:減掉畫布左上角、除以畫布實際寬高得到 0 到 1 的比例、乘上邏輯尺寸。
geometry.js 這個檔案第一行就是 export function,整個檔案零 import【實測:grep -n '^import' src/utils/*.js 回傳空】。它不知道 Pixi 存在,不知道 Matter 存在,不知道有幾個關卡。這件事的後果會在 Day 29 收,今天先記著。
還有一件可以查的事:getBoundingClientRect 在整個 src/ 只出現一次,就是上面第 33 行【實測:grep -rn 'getBoundingClientRect' src/】。clientX 也只出現兩次——這個函式的參數,跟唯一的呼叫端(src/systems/DrawingSystem.js:263)。整個專案跟瀏覽器座標打交道的地方,就這麼大。

一般講座標轉換會列三個坑:letterbox 留邊、頁面捲動、devicePixelRatio。這三個在這個專案裡的下場完全不同,而這張表本身就是本篇的論點:
| 坑 | 在這個專案裡發生了什麼 |
|---|---|
| letterbox 留邊 | 被 rect.left / rect.top 吸收。因為 letterbox 是用 CSS transform 做的(src/core/viewport.js:44),getBoundingClientRect() 回來的矩形已經含了那個位移——轉換函式從頭到尾不需要知道 letterbox 存在 |
| 頁面捲動 | 同樣被 rect.left 吸收。clientX 與 rect.left 都是相對視窗量的,相減之後捲動量自然抵銷。這也是為什麼這裡不能改用 pageX |
| devicePixelRatio | 這個函式完全沒碰它。它只在 CSS 像素的世界裡工作。DPR 被推到 Pixi 那一層:src/core/createApplication.js:17 的 resolution: Math.min(devicePixelRatio, MAX_RENDERER_RESOLUTION) 配 autoDensity: true,上限 2 定義在 src/config/game.js:7 |
不是「三個坑各寫一段程式碼去躲」,是選對量測基準之後,兩個自動消失,第三個根本不歸這一層管。
第三個那一列還藏著一個取捨。DPR 被夾在 2,意思是在 DPR 3 的手機上,渲染解析度只到兩倍——換來的是像素緩衝區不會變成九倍面積。這個上限是效能考量,記在 src/config/game.js:7 一個常數裡,這個專案沒有量過夾與不夾的畫面差異或幀率差異,所以我不會說它值得。我只能說這條線畫在 2,而且它畫在跟輸入完全無關的另一層。
量測基準指的是 getBoundingClientRect()。它回的是元素在螢幕上實際佔的 CSS 像素矩形——不是 canvas.width。這個差別在一般文章裡會被寫成「用 canvas.width 會在 Retina 上偏移一倍」,但我要說清楚:那個錯誤在這個專案裡不存在,因為這裡從頭到尾沒有讀過 canvas.width。我只能說「用了另一個基準就不會有那個問題」,不能說「我踩過那個坑」。
這個函式的測試不是抽象的(tests/unit/geometry.test.js:25-44):
it('maps browser coordinates to world coordinates', () => {
const canvas = {
getBoundingClientRect: () => ({
left: 10,
top: 20,
width: 375,
height: 667
})
}
expect(
clientToGamePoint({
clientX: 197.5,
clientY: 353.5,
canvas,
gameWidth: 750,
gameHeight: 1334
})
).toEqual({ x: 375, y: 667 })
})
這組數字可以用手算一遍,而且每一項都對應到一個真實情境:
| 值 | 代表什麼 |
|---|---|
left: 10、top: 20 |
letterbox 把畫布往右下推了 10 和 20 個 CSS 像素 |
width: 375、height: 667 |
一台 375×667 的直立手機,畫布正好貼滿 |
clientX: 197.5 |
手指按在畫布水平中線上(10 + 375 ÷ 2) |
期望值 x: 375 |
邏輯世界的水平中線(750 ÷ 2) |
(197.5 − 10) ÷ 375 × 750 = 375。畫布只有 375 CSS 像素寬,卻要裝下 750 個邏輯像素——縮放比 2;而那 10 像素的留邊在第一步就被減掉了。
toEqual 用的是嚴格相等,不是 toBeCloseTo。這組數字是刻意挑成整除的,所以偏一個像素測試就會紅。座標轉換是那種錯了不會報錯、只會「怪怪的」的程式碼,肉眼看得出整體偏移一半,看不出偏移三像素。
很多教學會建議:畫一個 debug 模式,把轉換後的座標直接畫在手指位置,對不上一眼就看到。
這個專案有 debug 疊層(src/ui/DebugHud.js,?debug=1 啟用),但它顯示的是 fps、單幀耗時、body 數、蜜蜂數(:99-121),沒有任何一行跟指標座標有關。所以那個驗證我沒做。這一層的信心全部來自上面那個單元測試,以及「它是純函式所以測得起來」這件事。

clientToGamePoint 不是直接被用的。呼叫端在外面又包了一層夾邊界(src/systems/DrawingSystem.js:260-271):把轉出來的點再送進 clampPoint(point, this.bounds),bounds 是 0 到 750、0 到 1334。
為什麼要夾?昨天講的 Pointer Capture:手指按住之後滑出畫布,事件還是會送回來,這時 clientX 會落在畫布矩形外面,轉換出來的值就會是負的或超過 750。夾一下,畫線的點就不會跑到世界外面。
這個因果我要標成推論。 程式碼裡沒有註解解釋為什麼要夾,PRD 裡也沒寫,clampPoint 更是一個直接測試都沒有【實測:grep -rn 'clampPoint' tests/ 回傳空】。上面那段是我從「有 capture」加「有夾」兩個事實推回去的最合理解釋,不是當初記下來的理由。
這一節是這篇最誠實的部分。同一個檔案裡的兩個函式,一個有帶具體數字的測試,一個什麼都沒有;有測試的那個我敢讓 AI 隨便改,沒測試的那個我不敢——差別不在誰比較難,在誰的錯誤會被自動抓到。
跟昨天一樣,這一層 AI 沒有設計空間:PRD.md:1138-1156 就有 clientToGamePoint 的完整程式碼,實作跟它逐行一致。所以這一節換個角度講:這個函式是整個專案裡我最放心讓 AI 動的一段。
理由不是它簡單,是它三個條件同時成立:
197.5 進去、375 出來,改壞了立刻紅,不需要人去看畫面。反過來,clampPoint 三項只中兩項——它一樣零 import、一樣純函式,但沒有人測它。能不能安心交給 AI,跟這段程式碼有多難沒什麼關係,跟它錯的時候誰會發現有關係。 Day 8 講的閘門是同一件事,只是換到了函式這個尺度。
一句話:
座標轉換不要靠寫更多分支去躲坑,要靠選對那個「已經含了所有位移」的量測基準。選對了,一半的坑不會發生;沒選對,你會為每一種螢幕寫一個 if。
三件今天就能做的事:
getBoundingClientRect(),不要用 canvas.width。 前者是 CSS 像素的實際位置,已經含了 letterbox 與捲動;後者是像素緩衝區大小。resolution 加 autoDensity,並且夾了上限 2。明天 Day 13,講收到手指之後那條線的完整生命週期。重點是 pointercancel:它不是例外處理,是正常路徑上的一條邊。我原本以為這個狀態機需要八個狀態,實際寫出來只有五個——多出來的那三個,剛好是三種「不該變成狀態」的東西。
本篇數字的快照時間:2026-08-07 12:35(+0800),對應 commit
5aa3705。專案仍在開發中,量體數字會變動;引用的每一項都可以用本文提到的檔案路徑自行對照。
可玩網址:https://save-the-dog-web.vercel.app/|原始碼:https://github.com/HarryFan/save-the-dog-web
如果你卡在語法
深入原理