iT邦幫忙

2026 iThome 鐵人賽

DAY 19
0
Claude AI

奇幻塔防開發實錄:用 Claude 打造一款有靈魂的塔防遊戲系列 第 19 篇

Day 19:RWD 深水區:讓塔防遊戲在手機瀏覽器也能操作自如的斷點策略

  • 分享至 

  • xImage
  •  

claude_19

系列:奇幻塔防開發實錄:用 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 — 設計稿原樣 設計稿原樣

claude_19_diagram_01

原則只有一條:畫布永遠保持 16:9 並填滿容器的短邊,其他 UI 圍著它排。直立手機不做「直立版戰場」——塔防的路徑設計是橫向的,硬塞進直立畫面只會讓塔位小到點不到,不如誠實地請玩家轉手機。劇情頁相反,直立反而好讀,所以「請轉橫向」的提示只掛在 BattleCanvas 上,不掛在全站——玩家在捷運上看劇情、到家再轉橫向打仗,是我希望的節奏。

二、useViewport:縮放、DPR、座標換算

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,並沒有比較簡單——少五行換來一張永遠糊的畫面,不划算。

三、useTouch:一套手勢,滑鼠與觸控都吃

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 底部抽屜與安全區

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 分鐘。這是前端週的固定成本,沒有工具能省,也不該省——模擬器的觸控永遠比真手指準,準到會騙你。

今日產出

  • [x] composables/{useViewport,useTouch}.ts、styles/breakpoints.css
  • [x] BattleCanvas.vue 改用 viewport 縮放並發出 tap / longPress
  • [x] Hud.vue 底部抽屜、直立提示、viewport-fit=cover
  • [x] 5 台實機測試表,Redmi 41 FPS 列為 Day 26 基準線

明日預告

Day 20:自訂 Workflow 實戰:用 ultracode 觸發 Claude Code 除錯一個難纏的碰撞判定 bug——弩箭為什麼穿過疾風掠奪者?


上一篇
Day 18:分支劇情前端呈現:對話框系統與選項介面的 RWD 設計實作
系列文
奇幻塔防開發實錄:用 Claude 打造一款有靈魂的塔防遊戲 共 19 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言