設計解析度 W × H 與螢幕 cssW × cssH 的比例幾乎不會相等,差距要嘛用黑邊補、要嘛用裁切吃掉、要嘛讓視野變大。三選一,沒有第四種不失真的可能——這是幾何上的必然,不是實作限制。以 1280×720(1.78)的設計跑在 iPhone 15 Pro 橫向(約 852×393,2.17)上,寬高比差了 22%,這 22% 一定要從某個地方付出去。
四種策略與它們的公式
令 sx = cssW / W、sy = 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 本質上就是這個),但它有三個必須事先回答的問題:
- 公平性。多人對戰中,21:9 的玩家比 4:3 的玩家看得更遠,這在 MOBA/FPS 是實質優勢。解法是對視野設上限:
worldW = Math.min(cssW / scale, MAX_WORLD_W),超過的部分再退回 letterbox 或裁切。 - 關卡邊界。視野變寬可能讓玩家看到地圖外的空白。關卡設計必須為最寬比例準備背景,或用鏡頭夾制(camera clamp)不讓視野越界。
- 美術驗收成本。要在多種比例下各看一次,不能只驗一張。
三者的選擇準則
| 問題 | letterbox | cover | expand |
|---|---|---|---|
| 所有玩家看到相同畫面? | 是 | 是(安全框內) | 否 |
| 有黑邊? | 有 | 無 | 無 |
| 內容會被裁掉? | 不會 | 會 | 不會 |
| 美術需要為多比例準備? | 不用 | 邊緣要有餘裕 | 要 |
| 適合 | 街機移植、益智、回合制 | 有背景可犧牲的休閒遊戲 | 動作、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 的 width 或 height(即使設成相同值)會把 bitmap 清空並把 drawing state 重設為初始值——fillStyle、font、lineWidth、變換矩陣、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 | 對應策略 |
|---|---|
contain | letterbox(維持比例,完整可見,留空白) |
cover | cover(維持比例,填滿,裁切) |
fill(預設) | stretch(變形) |
none | 原始尺寸,不縮放 |
scale-down | none 與 contain 中較小者 |
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 與清晰度