設計解析度 W × H 與螢幕 cssW × cssH 的比例幾乎不會相等,差距要嘛用黑邊補、要嘛用裁切吃掉、要嘛讓視野變大。三選一,沒有第四種不失真的可能——這是幾何上的必然,不是實作限制。以 1280×720(1.78)的設計跑在 iPhone 15 Pro 橫向(約 852×393,2.17)上,寬高比差了 22%,這 22% 一定要從某個地方付出去。

四種策略與它們的公式

sx = cssW / Wsy = cssH / H

letterbox (fit)   scale = min(sx, sy)     全部內容可見,短邊方向留黑邊
cover     (crop)  scale = max(sx, sy)     填滿螢幕,長邊方向被裁切
stretch           scaleX = sx, scaleY = sy   填滿且無裁切,但圖形變形
expand            scale = min(sx, sy) 對「安全框」計算,實際視野隨螢幕擴張

stretch 幾乎沒有正當用途——圓變成橢圓、字被壓扁,而且不同裝置變形程度不同,美術完全無法驗收。列出來只是為了說明它是唯一「既滿版又不裁切」的選項,代價高到不值得。

letterbox

const scale = Math.min(cssW / W, cssH / H)
const dispW = W * scale
const dispH = H * scale
const offsetX = (cssW - dispW) / 2
const offsetY = (cssH - dispH) / 2

優點:所有裝置看到完全相同的畫面,美術與關卡設計只需驗收一種構圖,競技公平性沒有爭議。缺點:黑邊。在 2.17 的螢幕上跑 1.78 的內容,左右各留 9% 的寬度,觀感上就是「這不是為手機做的」。

黑邊的處理有個中間路線:黑邊不留黑,改成延伸的背景。把背景層單獨用 cover 策略鋪滿整個容器,遊戲層用 letterbox 置中,視覺上是滿版的,玩法上是嚴格一致的。這是主機遊戲移植到手機最常見的折衷。

cover

const scale = Math.max(cssW / W, cssH / H)
// 被裁掉的量(單邊)
const cropX = (W * scale - cssW) / 2 / scale   // 換算回設計座標系
const cropY = (H * scale - cssH) / 2 / scale

優點:真滿版,沒有黑邊。缺點:靠邊的內容會被切掉,而且切掉多少因裝置而異。所以 cover 必須配合安全框:

SAFE_W = W * 0.85   →  安全框 1088×612(以 1280×720 為例)

規則是:所有 HUD 與必要資訊放在安全框內,安全框外只放可犧牲的裝飾與背景。 安全框要多小取決於你要支援的比例範圍:

支援比例範圍相對於設計比例的最大偏差建議安全框
只做桌機(16:9 ~ 16:10)~11%92%
桌機 + 平板(4:3 ~ 16:9)~33%80%
全裝置(4:3 ~ 21:9)~75%70%,或改用 expand

最後一列說明了 cover 的天花板:要同時支援 4:3 平板與 21:9 手機,安全框得縮到只剩畫面中央 70%,等於你有 30% 的畫布在多數裝置上都看不到——那不如一開始就別畫。這正是 expand 存在的理由。

expand(彈性視野)

expand 翻轉了問題:不是「把固定畫面塞進螢幕」,而是「保證安全框可見,剩下的空間就多給玩家看一點世界」。

// 安全框(保證可見的最小視野)
const SAFE_W = 1280, SAFE_H = 720
 
function layout(cssW, cssH) {
  const scale = Math.min(cssW / SAFE_W, cssH / SAFE_H)  // 安全框一定裝得下
  const worldW = cssW / scale       // 實際渲染的世界寬度,≥ SAFE_W
  const worldH = cssH / scale       // 實際渲染的世界高度,≥ SAFE_H
  return { scale, worldW, worldH }
}

在 2.17 的螢幕上,scale 由高度決定(cssH / 720),worldW 會變成 1560——你多看到左右各 140 單位的世界。畫面沒有黑邊、沒有裁切、沒有變形,只是不同裝置看到的世界範圍不同

這是現代手遊的主流做法(Unity 的 Canvas Scaler Match Width Or Height + Cinemachine 的鏡頭 framing 本質上就是這個),但它有三個必須事先回答的問題:

  1. 公平性。多人對戰中,21:9 的玩家比 4:3 的玩家看得更遠,這在 MOBA/FPS 是實質優勢。解法是對視野設上限:worldW = Math.min(cssW / scale, MAX_WORLD_W),超過的部分再退回 letterbox 或裁切。
  2. 關卡邊界。視野變寬可能讓玩家看到地圖外的空白。關卡設計必須為最寬比例準備背景,或用鏡頭夾制(camera clamp)不讓視野越界。
  3. 美術驗收成本。要在多種比例下各看一次,不能只驗一張。

三者的選擇準則

問題letterboxcoverexpand
所有玩家看到相同畫面?是(安全框內)
有黑邊?
內容會被裁掉?不會不會
美術需要為多比例準備?不用邊緣要有餘裕
適合街機移植、益智、回合制有背景可犧牲的休閒遊戲動作、RPG、模擬經營

決策順序:競技公平性有硬性要求 → letterbox;沒有但美術資源有限 → cover + 安全框;美術資源充足且要最好的滿版體驗 → expand。

完整實作:一個涵蓋三種策略的 stage 管理器

const MODE = 'expand'          // 'letterbox' | 'cover' | 'expand'
const DESIGN_W = 1280
const DESIGN_H = 720
const MAX_ASPECT = 2.4         // expand 模式下的視野上限(21.6:9)
const MAX_DPR = 2.5            // 高 DPR 裝置的像素預算上限
 
const root   = document.querySelector('.game-root')
const canvas = document.querySelector('#stage')
const ctx    = canvas.getContext('2d', { alpha: false })
 
const view = {
  scale: 1,        // 設計單位 → CSS 像素
  worldW: DESIGN_W, worldH: DESIGN_H,   // 實際渲染的世界尺寸(設計單位)
  offsetX: 0, offsetY: 0,               // 世界原點在 canvas 內的偏移(CSS 像素)
  dpr: 1,
}
 
function relayout(cssW, cssH) {
  const sx = cssW / DESIGN_W
  const sy = cssH / DESIGN_H
 
  let scale
  if (MODE === 'letterbox')      scale = Math.min(sx, sy)
  else if (MODE === 'cover')     scale = Math.max(sx, sy)
  else /* expand */              scale = Math.min(sx, sy)
 
  if (MODE === 'expand') {
    // 視野隨螢幕擴張,但不超過 MAX_ASPECT
    const aspect = Math.min(cssW / cssH, MAX_ASPECT)
    // 若螢幕比 MAX_ASPECT 更寬,就以上限視野再置中(等於局部 letterbox)
    const effW = cssH * aspect
    scale = Math.min(effW / DESIGN_W, cssH / DESIGN_H)
    view.worldW = cssW / scale
    view.worldH = cssH / scale
  } else {
    view.worldW = DESIGN_W
    view.worldH = DESIGN_H
  }
  view.scale = scale
 
  // canvas 的 CSS 尺寸:letterbox 只佔實際需要的大小,其餘策略滿版
  const dispW = MODE === 'letterbox' ? DESIGN_W * scale : cssW
  const dispH = MODE === 'letterbox' ? DESIGN_H * scale : cssH
  canvas.style.width  = dispW + 'px'
  canvas.style.height = dispH + 'px'
 
  // backing store:CSS 尺寸 × DPR(詳見筆記 05)
  const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR)
  const bw = Math.round(dispW * dpr)
  const bh = Math.round(dispH * dpr)
  if (canvas.width !== bw || canvas.height !== bh) {
    canvas.width = bw           // ⚠️ 這一行會重設整個 context 狀態
    canvas.height = bh
  }
  view.dpr = dpr
 
  // 世界原點在畫布內的位置(CSS 像素)。letterbox 與 expand 恆為 0,
  // cover 則為負值——正是「被裁掉的一半」。
  view.offsetX = (dispW - view.worldW * scale) / 2
  view.offsetY = (dispH - view.worldH * scale) / 2
}
 
function applyTransform() {
  // 一次設好:DPR 補償 × 置中偏移 × 遊戲縮放
  ctx.setTransform(
    view.scale * view.dpr, 0,
    0, view.scale * view.dpr,
    view.offsetX * view.dpr, view.offsetY * view.dpr
  )
}
 
function frame() {
  applyTransform()
  ctx.fillStyle = '#101014'
  ctx.fillRect(0, 0, view.worldW, view.worldH)   // 之後全部用設計座標畫圖
  drawGame(ctx, view)
  requestAnimationFrame(frame)
}

三個實作要點:

ctx.setTransform() 而不是 ctx.scale() scale() 是累加的,每幀呼叫會不斷疊乘;setTransform() 是絕對設定,每幀開頭呼叫一次就把矩陣重設成正確狀態,不需要 save()restore() 配對。

設定 canvas.width 會重設 context。 依規範,改變 canvas 的 widthheight(即使設成相同值)會把 bitmap 清空並把 drawing state 重設為初始值——fillStylefontlineWidth、變換矩陣、clip 全部歸零。所以上面的程式碼有 if (canvas.width !== bw) 的守衛,避免每次 resize 都白白清空;也因此 applyTransform() 必須在每幀重新呼叫。

只在畫圖前一刻讀 view,不要快取。 resize 可能發生在任何時刻,把 scale 拷貝到區域變數存著會導致某一幀用舊值渲染。

<img><video> 時:object-fit 就是同一組策略

背景圖與過場影片不需要自己算,CSS 已經提供了相同語意:

.bg {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  object-fit: cover;         /* = cover 策略:填滿並裁切 */
  object-position: 50% 40%;  /* 控制裁切錨點,人物臉部通常偏上 */
}
object-fit對應策略
containletterbox(維持比例,完整可見,留空白)
covercover(維持比例,填滿,裁切)
fill(預設)stretch(變形)
none原始尺寸,不縮放
scale-downnonecontain 中較小者

object-position 是 cover 策略中最實用的一個旋鈕:預設 50% 50% 從中心裁切,但構圖重點很少在正中央。直向手機上把橫幅背景設 object-position: 50% 30% 通常比置中好看得多。

檢查清單

  • 遊戲邏輯只用設計座標,沒有任何一行讀 window.innerWidth
  • 選定的策略有寫進文件,美術知道安全框在哪
  • expand 模式有設視野上限,且關卡背景有為最寬比例準備
  • ctx.setTransform() 每幀呼叫,不是 ctx.scale()
  • canvas.width 只在尺寸真的改變時才設
  • 在 4:3、16:9、20:9、21:9 四種比例各截圖驗收一次

下一篇處理「明明縮放對了,畫面還是糊」的那一層 → DPR 與清晰度