3.1 把渲染收成了「一块画布 + 三类指令」。但那块画布有多大、内容画在屏幕的哪个位置,不是你自己定的,是设备定的。同一个小游戏要跑在 320×568 的老 SE 到 1080×2400 的长屏安卓上,还要躲开刘海、圆角和底部指示条。站内的《游戏引擎实现原理》 讲的是管线内部的视口与裁剪矩阵,本节讲的是在既有引擎之上,把设计分辨率映射到各种真实屏幕、并算出安全区这件工程活——它不碰管线,只算矩形。
三个量:设计、屏幕、安全区
适配这件事,本质是三个尺寸之间的换算:
- 设计分辨率:美术按一套固定尺寸出图,比如 375×667,所有逻辑坐标都基于它。它是一切的基准。
- 屏幕分辨率:设备实际的整数像素尺寸,从 320×568 到 1080×2400 都有。
- 安全区:屏幕边缘不可用的内缩量——顶部刘海 / 状态栏、底部指示条、圆角。它是「能安全放 UI 的屏幕范围」。
适配要算的,就是把设计分辨率的内容区,按某个缩放塞进屏幕,再和安全区求交。四步:定缩放、算内容区、算居中偏移、与安全区求交。
fit 还是 fill
缩放只有两种基本策略,差别只在一个 min 和一个 max:
- fit:整幅内容可见,多出来的屏幕留黑边(letterbox)。取两轴缩放里较小的那个,保证不溢出。
- fill:铺满整个屏幕,超出的部分裁掉(cover)。取两轴缩放里较大的那个,保证不留边。
缩放比例本身用千分比整数表示(如 851 表示 0.851 倍),既避开浮点,又和本书「一份配置多处消费」的口径一致——同一份数字要被 Node 内核和浏览器原型逐位复现。
D 层:把适配写成配置
下面这份 D 层配置落在 config/e03_layout.json:一个设计分辨率、两种缩放模式、四台代表性设备(各自的屏幕尺寸与安全区内缩)。
{
"version": 1,
"game": "merge-island",
"note": "多分辨率适配配置:以设计分辨率 375×667 为基准,按 fit/fill 缩放,再把刘海/圆角/指示条的安全区内缩到内容区。所有尺寸为整数像素,缩放用千分比整数表示,禁浮点。",
"designWidth": 375,
"designHeight": 667,
"modes": ["fit", "fill"],
"devices": [
{ "id": "se", "label": "iPhone SE 320×568", "screenW": 320, "screenH": 568, "safeTop": 20, "safeRight": 0, "safeBottom": 0, "safeLeft": 0 },
{ "id": "p14pro", "label": "iPhone 14 Pro 393×852", "screenW": 393, "screenH": 852, "safeTop": 59, "safeRight": 0, "safeBottom": 34, "safeLeft": 0 },
{ "id": "ipad", "label": "iPad 10.2 810×1080", "screenW": 810, "screenH": 1080, "safeTop": 24, "safeRight": 0, "safeBottom": 20, "safeLeft": 0 },
{ "id": "android", "label": "Android 1080×2400", "screenW": 1080, "screenH": 2400, "safeTop": 96, "safeRight": 0, "safeBottom": 72, "safeLeft": 0 }
]
}
四台设备覆盖了三种典型形态:老比例小屏(SE)、带刘海的全面屏(14 Pro)、平板(iPad),外加一台超高长屏安卓。安全区只标了上下(safeTop / safeBottom),因为竖屏小游戏最容易被切的正是这两条边。
P 层:适配内核
下面这段是 P 层内核 core/src/e03/layout.ts。四个函数各管一步:axisScalePermille 算单轴缩放(整数地板除),computeScalePermille 按 fit/fill 取 min/max,computeLayout 算出内容区矩形与居中偏移,safeAreaRect 把内容区与安全区求交。
// file: core/src/e03/layout.ts
// tier: P
// verify: node --test 'core/test/e03_*.test.ts'
// 多分辨率适配与安全区:设计分辨率 → 屏幕的缩放(fit/fill)与居中偏移,再与安全区求交。
// 全程整数像素:缩放用千分比整数表示,除法一律 Math.floor,禁浮点。
export interface SafeInsets { readonly top: number; readonly right: number; readonly bottom: number; readonly left: number }
export interface DeviceProfile {
readonly id: string; readonly label: string; readonly screenW: number; readonly screenH: number;
readonly safeTop: number; readonly safeRight: number; readonly safeBottom: number; readonly safeLeft: number;
}
export interface LayoutConfig { readonly designWidth: number; readonly designHeight: number; readonly modes: string[]; readonly devices: DeviceProfile[] }
export interface Rect { readonly x: number; readonly y: number; readonly w: number; readonly h: number }
export interface Layout extends Rect { readonly screenW: number; readonly screenH: number; readonly mode: string; readonly scalePermille: number }
export function insetsOf(d: DeviceProfile): SafeInsets {
return { top: d.safeTop, right: d.safeRight, bottom: d.safeBottom, left: d.safeLeft };
}
// 单轴缩放千分比:屏幕长度 × 1000 ÷ 设计长度,整数地板除。
export function axisScalePermille(designLen: number, screenLen: number): number { return Math.floor((screenLen * 1000) / designLen); }
// fit 取两轴较小值(整幅可见、留黑边);fill 取较大值(铺满、超出裁掉)。
export function computeScalePermille(designW: number, designH: number, screenW: number, screenH: number, mode: string): number {
const sx = axisScalePermille(designW, screenW), sy = axisScalePermille(designH, screenH);
return mode === "fill" ? Math.max(sx, sy) : Math.min(sx, sy);
}
// 内容区在屏幕上的整数像素矩形;偏移可正可负(fill 溢出时居中裁切)。
export function computeLayout(designW: number, designH: number, screenW: number, screenH: number, mode: string): Layout {
const sp = computeScalePermille(designW, designH, screenW, screenH, mode);
const contentW = Math.floor((designW * sp) / 1000), contentH = Math.floor((designH * sp) / 1000);
return { screenW, screenH, mode, scalePermille: sp,
x: Math.floor((screenW - contentW) / 2), y: Math.floor((screenH - contentH) / 2), w: contentW, h: contentH };
}
// 安全区 = 屏幕可见区(扣掉刘海/圆角/指示条)∩ 设计内容区,返回整数像素矩形。
export function safeAreaRect(layout: Layout, insets: SafeInsets): Rect {
const x0 = Math.max(layout.x, insets.left), y0 = Math.max(layout.y, insets.top);
const x1 = Math.min(layout.x + layout.w, layout.screenW - insets.right);
const y1 = Math.min(layout.y + layout.h, layout.screenH - insets.bottom);
return { x: x0, y: y0, w: Math.max(0, x1 - x0), h: Math.max(0, y1 - y0) };
}
// 屏幕坐标 → 设计坐标(整数地板除),用于把一次触摸换算回设计空间。
export function toDesign(layout: Layout, px: number, py: number): { x: number; y: number } {
return { x: Math.floor(((px - layout.x) * 1000) / layout.scalePermille),
y: Math.floor(((py - layout.y) * 1000) / layout.scalePermille) };
}
P 层:把适配结果钉死。 四台设备的缩放、内容区、偏移,以及两处安全区求交,全部写成断言——任何一次改动若挪动了布局,测试立刻红。
// file: core/test/e03_layout.test.ts
// tier: P
// verify: node --test 'core/test/e03_*.test.ts'
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { axisScalePermille, computeScalePermille, computeLayout, safeAreaRect, toDesign, insetsOf } from "../src/e03/layout.ts";
const HERE = dirname(fileURLToPath(import.meta.url));
const cfg = JSON.parse(readFileSync(join(HERE, "..", "..", "config", "e03_layout.json"), "utf-8"));
const DW = cfg.designWidth, DH = cfg.designHeight;
const dev = (id: string) => cfg.devices.find((d: { id: string }) => d.id === id);
test("单轴缩放千分比整数地板除,fit 取小 fill 取大", () => {
assert.equal(axisScalePermille(375, 320), 853);
assert.equal(axisScalePermille(667, 568), 851);
assert.equal(computeScalePermille(DW, DH, 320, 568, "fit"), 851);
assert.equal(computeScalePermille(DW, DH, 393, 852, "fit"), 1048);
assert.equal(computeScalePermille(DW, DH, 393, 852, "fill"), 1277);
});
test("fit 布局:四台设备的内容区与居中偏移(整数像素)", () => {
const L = (id: string) => computeLayout(DW, DH, dev(id).screenW, dev(id).screenH, "fit");
assert.deepEqual([L("se").scalePermille, L("se").w, L("se").h, L("se").x, L("se").y], [851, 319, 567, 0, 0]);
assert.deepEqual([L("p14pro").scalePermille, L("p14pro").w, L("p14pro").h, L("p14pro").x, L("p14pro").y], [1048, 393, 699, 0, 76]);
assert.deepEqual([L("ipad").scalePermille, L("ipad").w, L("ipad").h, L("ipad").x, L("ipad").y], [1619, 607, 1079, 101, 0]);
assert.deepEqual([L("android").scalePermille, L("android").w, L("android").h, L("android").x, L("android").y], [2880, 1080, 1920, 0, 240]);
});
test("fit 不溢出;fill 允许溢出且偏移可为负", () => {
for (const d of cfg.devices) {
const l = computeLayout(DW, DH, d.screenW, d.screenH, "fit");
assert.ok(l.w <= l.screenW && l.h <= l.screenH, `${d.id} fit 不应溢出`);
assert.ok(l.x >= 0 && l.y >= 0, `${d.id} fit 偏移应非负`);
}
const f = computeLayout(DW, DH, dev("p14pro").screenW, dev("p14pro").screenH, "fill");
assert.deepEqual([f.w, f.h, f.x, f.y], [478, 851, -43, 0]);
});
test("安全区求交:iPad fit 被指示条/圆角切到,14 Pro fill 被刘海切到", () => {
const ip = computeLayout(DW, DH, dev("ipad").screenW, dev("ipad").screenH, "fit");
assert.deepEqual(safeAreaRect(ip, insetsOf(dev("ipad"))), { x: 101, y: 24, w: 607, h: 1036 });
const pf = computeLayout(DW, DH, dev("p14pro").screenW, dev("p14pro").screenH, "fill");
assert.deepEqual(safeAreaRect(pf, insetsOf(dev("p14pro"))), { x: 0, y: 59, w: 393, h: 759 });
const se = computeLayout(DW, DH, dev("se").screenW, dev("se").screenH, "fit");
assert.deepEqual(safeAreaRect(se, insetsOf(dev("se"))), { x: 0, y: 20, w: 319, h: 547 });
});
test("屏幕坐标 → 设计坐标:可复现且左上角映射到原点", () => {
const p = computeLayout(DW, DH, dev("p14pro").screenW, dev("p14pro").screenH, "fit");
assert.deepEqual(toDesign(p, 196, 400), { x: 187, y: 309 });
assert.deepEqual(toDesign(p, 196, 400), toDesign(p, 196, 400));
assert.deepEqual(toDesign(p, p.x, p.y), { x: 0, y: 0 });
});
跑 node --test 'core/test/e03_*.test.ts',适配内核这 5 个用例全过。四台设备的 fit 布局结果如下(同机相对量级,绝对数值不可移植):
| 设备 | 缩放‰ | 内容区 | 偏移 | 安全区 |
|---|---|---|---|---|
| iPhone SE 320×568 | 851 | 319×567 | 0,0 | 319×547 @0,20 |
| iPhone 14 Pro 393×852 | 1048 | 393×699 | 0,76 | 393×699 @0,76 |
| iPad 10.2 810×1080 | 1619 | 607×1079 | 101,0 | 607×1036 @101,24 |
| Android 1080×2400 | 2880 | 1080×1920 | 0,240 | 1080×1920 @0,240 |
读法很直白:SE 因两轴比例接近设计分辨率,缩放接近 0.85;14 Pro 屏更高,纵向留了 76 px 黑边;iPad 屏更宽,横向留了 101 px 黑边;安卓长屏缩放高达 2.88,纵向留 240 px 黑边。安全区列才是重点:iPad 的 home indicator 区把内容上下各切掉一段(1079 → 1036),而 14 Pro 的刘海(top=59)比内容区上边(y=76)还靠上,fit + 居中让刘海根本没碰到内容——这是 fit 模式的一个隐性好处。
W 层:可切换分辨率的页面
下面这段是 W 层构建脚本 web/build_e03_layout.mjs。它把适配内核内联进单文件 HTML,页面上有「切换设备」和「切换 fit / fill」两个按钮,实时画出屏幕 / 内容区 / 安全区三层矩形。
#!/usr/bin/env node
// file: web/build_e03_layout.mjs
// tier: W
// verify: node web/build_e03_layout.mjs
// 把 core/src/e03/layout.ts 的适配内核打成单文件 HTML:可切换设备与 fit/fill,
// 实时画出屏幕/内容区/安全区三层矩形。产物 web/dist/e03_layout.html。
import { readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { bundle, page, writePage, inlineConfig } from "./pack.mjs";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const config = JSON.parse(readFileSync(join(ROOT, "config", "e03_layout.json"), "utf8"));
const js = bundle(ROOT, ["core/src/e03/layout.ts"]);
const script = `const cfg = ${inlineConfig(config)};
/* ---- core bundle: TS -> strip -> inline ---- */
${js}
/* ---- end bundle ---- */
const DEVICES = cfg.devices;
let di = 0, mi = 0;
const cv = document.getElementById("preview"), ctx = cv.getContext("2d");
const set = (id, v) => { document.getElementById(id).textContent = String(v); };
function render() {
const d = DEVICES[di], mode = cfg.modes[mi];
const L = computeLayout(cfg.designWidth, cfg.designHeight, d.screenW, d.screenH, mode);
const S = safeAreaRect(L, insetsOf(d));
// 预览缩放:把设备屏幕按整数千分比缩放进预览画布,居中摆放。
const pw = cv.width, ph = cv.height;
const ps = Math.min(Math.floor((pw * 1000) / d.screenW), Math.floor((ph * 1000) / d.screenH));
const sw = Math.floor((d.screenW * ps) / 1000), sh = Math.floor((d.screenH * ps) / 1000);
const ox = Math.floor((pw - sw) / 2), oy = Math.floor((ph - sh) / 2);
const map = (r) => ({ x: ox + Math.floor((r.x * ps) / 1000), y: oy + Math.floor((r.y * ps) / 1000),
w: Math.floor((r.w * ps) / 1000), h: Math.floor((r.h * ps) / 1000) });
ctx.clearRect(0, 0, pw, ph);
ctx.fillStyle = "#0b1220"; ctx.fillRect(ox, oy, sw, sh); // 屏幕
const c = map(L); ctx.fillStyle = "#1d4ed8"; ctx.fillRect(c.x, c.y, c.w, c.h); // 设计内容区
const s = map(S); ctx.fillStyle = "#22c55e"; ctx.fillRect(s.x, s.y, s.w, s.h); // 安全区
set("label", d.label); set("mode", mode); set("scale", L.scalePermille);
set("content", L.w + "×" + L.h); set("offset", L.x + "," + L.y);
set("safe", S.w + "×" + S.h + " @" + S.x + "," + S.y);
globalThis.__pebble = { deviceId: d.id, label: d.label, mode, scalePermille: L.scalePermille,
contentW: L.w, contentH: L.h, offsetX: L.x, offsetY: L.y,
safeRect: { x: S.x, y: S.y, w: S.w, h: S.h }, canvasDrawn: true };
}
document.getElementById("device").addEventListener("click", () => { di = (di + 1) % DEVICES.length; render(); });
document.getElementById("mode").addEventListener("click", () => { mi = (mi + 1) % cfg.modes.length; render(); });
render();
`;
const body = ` <h1>《合成小岛》· 多分辨率适配与安全区</h1>
<div class="sub">内核由 core/src/e03/layout.ts 提供:fit/fill 缩放 + 安全区求交,全程整数像素</div>
<div class="card row"><div><div class="k">设备</div><div class="big" id="label">-</div></div><div><div class="k">模式</div><div class="big" id="mode">-</div></div><div><div class="k">缩放‰</div><div class="big" id="scale">0</div></div></div>
<div class="card row"><div><div class="k">内容区</div><div class="big" id="content">0×0</div></div><div><div class="k">偏移</div><div class="big" id="offset">0,0</div></div><div><div class="k">安全区</div><div class="big" id="safe">0×0</div></div></div>
<div class="card"><div class="k">屏幕 / 内容区(蓝)/ 安全区(绿)</div><canvas id="preview" width="360" height="480"></canvas></div>
<div class="card row"><button id="device">切换设备</button><button id="mode">切换 fit / fill</button></div>`;
const bytes = writePage(join(ROOT, "web/dist/e03_layout.html"),
page({ title: "《合成小岛》多分辨率适配原型 · e03", css: "#preview { height: auto; }", body, script }));
console.log(JSON.stringify({ html: "web/dist/e03_layout.html", htmlBytes: bytes, bundleLines: js.split("\n").length }, null, 2));
W 层:无头验收。 web/verify_e03_layout.mjs 用 Playwright 无头打开页面,点真实按钮切换设备与 fit/fill,逐档断言布局数字与安全区矩形。
#!/usr/bin/env node
// file: web/verify_e03_layout.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_e03_layout.mjs web/dist/e03_layout.html
// W 层验收:Playwright 无头打开页面,点真实按钮切换设备与 fit/fill,逐档断言布局数字与安全区矩形,
// 检查画布真画了像素、页面无报错,并截图。
import { createRequire } from "node:module";
import { pathToFileURL, fileURLToPath } from "node:url";
import { dirname, join, resolve } from "node:path";
import { mkdirSync } from "node:fs";
import assert from "node:assert/strict";
const { chromium } = createRequire(import.meta.url)("playwright");
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const url = pathToFileURL(resolve(ROOT, process.argv[2] ?? "web/dist/e03_layout.html")).href;
mkdirSync(join(ROOT, "out/shots"), { recursive: true });
const browser = await chromium.launch();
const warm = await browser.newPage(); await warm.goto(url); await warm.close(); // 预热:冷启动首个 page 读 canvas 像素恒为空白
const errors = [];
const page = await browser.newPage({ viewport: { width: 760, height: 900 } });
page.on("pageerror", (e) => errors.push(String(e)));
await page.goto(url);
await page.waitForFunction(() => globalThis.__pebble?.canvasDrawn === true, null, { timeout: 10000 });
const snap = () => page.evaluate(() => globalThis.__pebble);
const se = await snap();
assert.equal(se.deviceId, "se"); assert.equal(se.mode, "fit");
assert.deepEqual([se.scalePermille, se.contentW, se.contentH, se.offsetX, se.offsetY], [851, 319, 567, 0, 0]);
assert.deepEqual(se.safeRect, { x: 0, y: 20, w: 319, h: 547 });
await page.click("#device");
const p = await snap();
assert.equal(p.deviceId, "p14pro");
assert.deepEqual([p.scalePermille, p.contentW, p.contentH, p.offsetY], [1048, 393, 699, 76]);
await page.click("#mode");
const pf = await snap();
assert.equal(pf.mode, "fill");
assert.deepEqual([pf.scalePermille, pf.contentW, pf.contentH, pf.offsetX, pf.offsetY], [1277, 478, 851, -43, 0]);
assert.deepEqual(pf.safeRect, { x: 0, y: 59, w: 393, h: 759 });
await page.click("#mode");
await page.click("#device");
const ip = await snap();
assert.equal(ip.deviceId, "ipad"); assert.equal(ip.mode, "fit");
assert.deepEqual(ip.safeRect, { x: 101, y: 24, w: 607, h: 1036 });
const painted = await page.evaluate(() => {
const cv = document.getElementById("preview");
const d = cv.getContext("2d").getImageData(0, 0, cv.width, cv.height).data;
let n = 0; for (let i = 0; i < d.length; i += 4) if (d[i + 3] > 0) n += 1;
return n;
});
await page.screenshot({ path: join(ROOT, "out/shots/e03_layout.png"), fullPage: true });
await browser.close();
assert.equal(errors.length, 0, `页面错误: ${errors.join("; ")}`);
assert.ok(painted > 20000, `预览画布应有绘制,实测 ${painted} px`);
console.log(JSON.stringify({ page: "web/dist/e03_layout.html",
se: { scale: se.scalePermille, content: [se.contentW, se.contentH], safe: se.safeRect },
p14proFit: { scale: p.scalePermille, content: [p.contentW, p.contentH], offsetY: p.offsetY },
p14proFill: { scale: pf.scalePermille, content: [pf.contentW, pf.contentH], offsetX: pf.offsetX, safe: pf.safeRect },
ipadFitSafe: ip.safeRect, canvasPaintedPx: painted, screenshot: "out/shots/e03_layout.png" }, null, 2));
console.log("SMOKE-W3L: PASS");
跑出来(同机相对量级,绝对数值不可移植):SE/fit 缩放 851、内容区 319×567、安全区 319×547@0,20;切到 14 Pro/fit 缩放 1048、纵向偏移 76;再切到 fill 缩放跳到 1277、内容区 478×851、横向偏移 -43(内容比屏幕宽,居中后左侧溢出 43 px)、安全区被刘海切到 393×759@0,59;切回 fit 再切到 iPad,安全区 607×1036@101,24。预览画布绘制 171961 px,SMOKE-W3L: PASS,截图落在 out/shots/e03_layout.png。
这一节要记住的三件事
- 缩放用千分比整数,不用浮点。
851比0.851更能跨语言逐位复现,也让「fit 取小、fill 取大」变成两次整数比较。 - fit 与 fill 只差一个 min/max,但产品后果不同。fit 保内容完整、可能留黑边;fill 保铺满、必然裁内容。选哪个取决于游戏——棋盘类小游戏通常 fit,全屏背景类通常 fill。
- 安全区必须与内容区求交,而不是直接套用。fit + 居中常常让刘海碰不到内容(14 Pro 的 top=59 < 内容上边 76),真正会被切到的是平板那种上下都贴边的布局。
必须诚实标注:safeTop / safeBottom 这些内缩量是按公开可观察的设备形态取的近似值,不是任何真实机型逐像素量出来的;designWidth=375 / designHeight=667 也只是常见基准之一。本节能确定的是换算方法与断言口径(整数千分比、求交、可复现),真实机型的精确安全区需真机测量,属于「需真实业务环境,命令行不可复现」的范畴。
阅读导航:上一节:小游戏的渲染环境:无 DOM 的 Canvas / WebGL · 下一节:渲染性能与批处理 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。