
系列:奇幻塔防開發實錄:用 Claude 打造一款有靈魂的塔防遊戲
今日工具:Claude Code / 實機測試(iPhone、Android、iPad)
今日進度:960×540 畫布等比縮放到任何螢幕、DPR 不糊、長按開塔選單、HUD 變底部抽屜、直立時提示轉橫向;5 台實機的 FPS 與可點性數據
Day 18 收尾時,我在 iPhone 上打開 /battle/ashfield:畫布以原始的 960px 寬硬撐出去,右邊 40% 的戰場在螢幕外,塔位怎麼點都沒反應。桌機上的 Canvas 只是「一張圖」,手機上它是輸入裝置——座標、縮放、手勢、安全區,四件事都要重新想。而且 README 一開始就承諾「支援 RWD 讓手機瀏覽器也能順暢操作」,這句話今天要兌現。
今天的策略是邏輯座標不動,只縮外層。引擎永遠活在 960×540 裡,RWD 是 useViewport 與 CSS 的事,Renderer 一行都不改。
我先跟 Claude Code 討論出一張表,再寫程式。三個斷點來自 Day 2 設計稿的四張 artboard(桌機、平板、手機橫向、對話框),不是從某個 CSS 框架抄來的:
| 斷點 | 變數 | 戰鬥頁的變化 | 劇情頁的變化 |
|---|---|---|---|
< 480px |
--bp-sm |
幾乎只會是直立手機 → 顯示「請轉橫向」 | 頭像 48px、字級下限 14px |
480–767px |
--bp-md |
HUD 從右欄改成底部抽屜;塔選單改長按彈出 | 選項按鈕全寬 |
768–1023px |
--bp-lg |
右欄 HUD 保留,畫布縮放 | 兩欄 |
≥ 1024px |
— | 設計稿原樣 | 設計稿原樣 |

原則只有一條:畫布永遠保持 16:9 並填滿容器的短邊,其他 UI 圍著它排。直立手機不做「直立版戰場」——塔防的路徑設計是橫向的,硬塞進直立畫面只會讓塔位小到點不到,不如誠實地請玩家轉手機。劇情頁相反,直立反而好讀,所以「請轉橫向」的提示只掛在 BattleCanvas 上,不掛在全站——玩家在捷運上看劇情、到家再轉橫向打仗,是我希望的節奏。
Prompt(給 Claude Code)
「寫composables/useViewport(canvas, container):畫布固定 960×540 邏輯座標,等比縮放填滿容器短邊,支援 devicePixelRatio,提供toLogical(clientX, clientY),resize與轉向時重新計算。Renderer 不能知道縮放的存在。」
// frontend/src/composables/useViewport.ts(節錄)
function fit(): void {
const el = container.value, cv = canvas.value
if (!el || !cv) return
const dpr = window.devicePixelRatio || 1
const s = Math.min(el.clientWidth / LOGICAL_WIDTH, el.clientHeight / LOGICAL_HEIGHT)
scale.value = s
cv.style.width = `${Math.floor(LOGICAL_WIDTH * s)}px` // CSS 尺寸:看到的大小
cv.style.height = `${Math.floor(LOGICAL_HEIGHT * s)}px`
cv.width = Math.round(LOGICAL_WIDTH * s * dpr) // backing store:乘 DPR 才不糊
cv.height = Math.round(LOGICAL_HEIGHT * s * dpr)
cv.getContext('2d')?.setTransform(s * dpr, 0, 0, s * dpr, 0, 0) // 每次 fit 都要重套
isPortrait.value = window.innerHeight > window.innerWidth
}
/** 把螢幕座標換成 960×540 的邏輯座標 */
function toLogical(clientX: number, clientY: number): Point {
const rect = canvas.value!.getBoundingClientRect()
return [(clientX - rect.left) / scale.value, (clientY - rect.top) / scale.value]
}
兩個坑都藏在註解裡。第一,canvas.width 一旦被賦值,2D context 的所有狀態(包含 transform)會被重設,所以 setTransform 必須在 fit() 裡跟著做,不能只在 mount 時做一次——我第一版就是這樣,轉向之後所有東西縮成左上角的一小塊。第二,backing store 要乘 DPR,否則 iPhone(DPR 3)上的線條與文字都是糊的;但 Renderer 完全不需要知道 DPR 的存在,它照樣畫在 960×540,transform 幫它換算。
fit() 綁在 resize 與 screen.orientation.change 兩個事件上。iOS Safari 轉向時 resize 會晚一拍,多綁 orientation 才不會出現半秒的錯位。這是 Claude Code 在我描述「轉向後閃一下」的症狀時直接指出的,它甚至先問了我是不是 Safari。
我也試過只用 CSS transform: scale() 縮整個 canvas、不動 backing store:程式少五行,但 iPhone 上文字糊、路徑鋸齒,toLogical 一樣要除以 scale,並沒有比較簡單——少五行換來一張永遠糊的畫面,不划算。
Pointer Events 讓我不用分 touchstart / mousedown 兩套。規則很簡單:按下 450ms 內放開且移動小於容忍值是 tap;超過 450ms 沒放開是 long-press;中途移動超過容忍值就取消。
// frontend/src/composables/useTouch.ts(節錄)
const down = (e: PointerEvent): void => {
startX = e.clientX; startY = e.clientY; fired = false
clear()
timer = window.setTimeout(() => { fired = true; handlers.onLongPress(startX, startY) }, longPressMs)
}
const move = (e: PointerEvent): void => {
if (timer !== null && Math.hypot(e.clientX - startX, e.clientY - startY) > moveTolerance) clear()
}
const up = (e: PointerEvent): void => {
const pending = timer !== null
clear()
if (pending && !fired) handlers.onTap(e.clientX, e.clientY)
}
BattleView.vue 收到 tap / longPress(已經是邏輯座標)後找塔位。這裡有一個只有實機才會發現的數字:塔位判定半徑 24px,在 iPhone 13 mini 上乘以 0.69 的 scale 只剩 17px,手指根本點不準。修法不是把塔位畫大(那會改到設計稿),而是在 BattleView 用 window.matchMedia('(pointer: coarse)') 偵測粗指標裝置,命中時把判定半徑加 12px 的 hit slop。12 這個數字來自 Fitts 定律的老規矩:手指的有效接觸面約 7mm,在 0.69 的 scale 下大約就是這個量。
桌機保持單擊開選單,手機則是單擊選取、長按開選單——因為手機上單擊塔位常常是誤觸,長按才是「我確定要對這座塔做事」。畫布本身加 touch-action: none,否則長按會觸發 iOS 的放大鏡與選字,user-select: none 一起加。
Hud.vue 在 < 768px 時從右欄變成固定在底部的橫向抽屜,四個數字(金幣、生命、薪火、波次)各佔 20%,兩個按鈕各佔 45%。要記得 iPhone 的 home indicator:
@media (max-width: 767px) {
.hud { flex-direction: row; flex-wrap: wrap; position: fixed; inset: auto 0 0 0;
padding: 8px env(safe-area-inset-right) calc(8px + env(safe-area-inset-bottom)) env(safe-area-inset-left); }
}
env(safe-area-inset-*) 要搭配 index.html 的 <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> 才會生效,這一行 Claude Code 在我貼上 CSS 之後主動提醒,省了我一次實機來回。抽屜會遮住畫布最下面約 60px,所以 BattleCanvas 的容器在這個斷點要把 height 減掉抽屜高度,讓 fit() 算出的 scale 小一點,而不是讓路徑的最後一段躲在抽屜底下。
量測方式:?debug=1 會在畫布左上角疊一個 FPS 計數器(每秒 requestAnimationFrame 次數),數字是第 5 波(同屏最多約 20 隻敵人)10 秒的平均;「可點性」是連續點 20 次不同塔位、成功開出選單的次數。全部用 Day 16 部署的 CloudFront 網址測,不是本機(/api/* 現在是 CloudFront 代理到 API Gateway,所以實機測的是真的線上路徑)。FPS 計數器是一個 20 行的 composable:每幀 count++,每秒把 count 寫進一個 ref 後歸零,畫在疊於畫布上方的 <div> 裡——Renderer 完全不知道它的存在,引擎不為量測工具付出任何成本。
| 裝置 | 瀏覽器 | 視窗(CSS px,橫向) | scale | 第 5 波 FPS | 可點性(/20) | 備註 |
|---|---|---|---|---|---|---|
| iPhone 13 mini | Safari 17.6 | 812×375 | 0.69 | 58.7 | 20 | 加 hit slop 前只有 13 |
| iPhone 15 Pro | Safari 17.6 | 852×393 | 0.73 | 60.0 | 20 | ProMotion 仍鎖 60 |
| Pixel 7 | Chrome 128 | 915×412 | 0.76 | 59.6 | 20 | — |
| iPad mini 6 | Safari 17.6 | 1133×744 | 1.18 | 60.0 | 20 | 走 ≥ 1024 版面 |
| Redmi Note 8 | Chrome 128 | 780×360 | 0.67 | 41.3 | 17 | 長按偶爾被判成拖曳,容忍值 8 → 12 後 19/20 |
Redmi 的 41 FPS 是 Day 26 效能優化的基準線——它是我手邊最弱的裝置,也最接近「隨便一台四年前的中階機」。另一個在手機上更明顯的現象:第 4 波 10 隻疾風掠奪者衝過來時,弩塔的箭大量落空。Day 17 記下的那件「怪事」,在掉幀的手機上從「偶爾」變成「常常」,我用 Performance 面板錄了一段:大約每 6 幀有一幀拖到 100ms。明天就用它來試 /ultracode。
RWD 對 Canvas 遊戲來說不是 media query 的問題,而是座標系統的問題。把「引擎永遠 960×540」當成鐵律之後,剩下的都是外層的事:縮放、DPR、手勢、安全區。Claude Code 在這四件事上都給了正確的第一版,也主動補了兩個我會漏的細節(orientation 事件、viewport-fit=cover)。但三個關鍵數字——hit slop 12px、長按容忍值 12px、抽屜高度 60px——只有拿著手機才調得出來。
實機測試佔了今天 210 分鐘裡的 90 分鐘。這是前端週的固定成本,沒有工具能省,也不該省——模擬器的觸控永遠比真手指準,準到會騙你。
composables/{useViewport,useTouch}.ts、styles/breakpoints.css
BattleCanvas.vue 改用 viewport 縮放並發出 tap / longPress
Hud.vue 底部抽屜、直立提示、viewport-fit=cover
Day 20:自訂 Workflow 實戰:用 ultracode 觸發 Claude Code 除錯一個難纏的碰撞判定 bug——弩箭為什麼穿過疾風掠奪者?