事故:HUD 在三种手机上各坏一次
《潮汐消消》早期按 750×1334 的视觉稿做 HUD,所有坐标都写死。第一轮外测收到三类反馈:刘海屏手机上,左上角的体力条被状态栏遮住;带 Home 指示条的手机上,底部「开始」按钮的一部分落在系统手势区,点击经常被系统截走;平板 4:3 上,背景只铺到中间一条,两侧露出黑边。
三个问题的共同点不是「适配没做」,而是把一套设计坐标当成了屏幕坐标。设计稿只定义相对关系;屏幕尺寸、显示缩放和安全区由设备决定。把它们混在一起,任何一处写死都会在另一类设备上变成可见缺陷。
本节只做布局计算:给定视口、安全区与设计尺寸,算出背景、安全矩形、顶部栏、底部栏和按钮矩形。它是无头模型,不解码图片、不测量字体、不调用 GPU,因此不能用它宣称「渲染正确」。真实设备上仍要检查文字截断、九宫格拉伸与触摸热区。
逻辑分辨率是一套映射,不是一种分辨率
移动端游戏通常选一个设计尺寸,再用一个缩放策略映射到视口。常见策略有两种:contain 保证设计稿完整可见,代价是黑边;cover 保证屏幕铺满,代价是裁掉一条边。全屏游戏几乎都选 cover,因为黑边比裁切更显眼。
| 策略 | 缩放公式 | 结果 | 适用 |
|---|---|---|---|
| contain | min(vw/dw, vh/dh) | 完整设计稿,可能上下或左右留黑边 | 工具页、过场动画 |
| cover | max(vw/dw, vh/dh) | 铺满视口,裁掉较长的一轴 | 游戏 HUD、全屏背景 |
模型采用 cover:设计尺寸 750×1334,缩放比例是宽高比例的较大值。缩放只决定「一个设计单位对应多少屏幕点」,不决定安全区;安全区来自设备的显示内边距,必须单独映射。
这里有一个容易混淆的换算:安全区 inset 的单位是屏幕点,不是设计单位。把 47pt 的刘海直接加到设计坐标上,在 0.63 缩放下会多留近一倍空间。正确做法是先除以缩放比例,再叠加到可见设计区域的原点上。
安全区是显示坐标,不是设计坐标
安全区的定义是「系统 UI 或硬件开孔占用的屏幕边缘」。它有三个来源:状态栏与刘海、底部 Home 指示条、以及折叠屏或平板的圆角与铰链。三者都只影响显示,不影响设计稿本身。
| 形状 | 视口(pt) | 上/下 inset(pt) | 缩放 | 横向裁切(pt) | 纵向裁切(pt) | 安全区顶部(设计单位) |
|---|---|---|---|---|---|---|
| legacy-16x9 | 360×640 | 0 / 0 | 0.4800 | 0 | 0.32 | 0.33 |
| notch-19.5x9 | 390×844 | 47 / 34 | 0.6327 | 84.51 | 0 | 74.29 |
| punch-hole-20x9 | 360×800 | 24 / 24 | 0.5997 | 89.78 | 0 | 40.02 |
| tablet-4x3 | 768×1024 | 0 / 20 | 1.0240 | 0 | 342.02 | 167.00 |
表中数字是模型对案例设定视口的输出,不是设备测量。可以看到两个反直觉结果:刘海屏与打孔屏在 cover 下先横向裁掉了 84–90pt,因此左右没有安全区,但顶部必须从设计坐标 74.29 / 40.02 处才开始放 HUD;平板 4:3 反而纵向裁掉 342pt,设计稿的上下各有一条永远看不到。
安全区映射的正确顺序是:先算可见设计区域,再把 inset 除以缩放后叠加到可见区域原点,最后与可见区域求交。不能直接拿设计稿的 0 点当屏幕左上角,也不能把安全区当成「往里缩一圈」的固定边距。
锚点与布局模型:四条边、九个锚点
HUD 元素不应写绝对坐标,而应锚定在安全矩形的边上。常用九宫格锚点足够覆盖大多数情况:左上、上中、右上、左中、居中、右中、左下、下中、右下。锚点解决的是「元素跟随哪条边」,偏移量解决「离边多远」。
| 元素 | 锚点 | 偏移 | 约束 |
|---|---|---|---|
| 暂停键 | 顶栏右上 | 右边距 8 设计单位 | 完整落在安全区内 |
| 主按钮 | 底栏下中 | 底边距 12 设计单位 | 不低于 44pt 触控尺寸 |
| 体力条 | 顶栏左上 | 左边距 16 设计单位 | 不与暂停键重叠 |
| 背景 | 视口四边 | 无 | 不受安全区限制,必须全屏 |
顶栏和底栏本身也锚在安全矩形上:顶栏贴着安全区顶部,底栏贴着安全区底部。这样刘海、Home 指示条、平板圆角都只改变安全矩形,不改变元素之间的相对关系。背景是唯一允许越过安全区的元素,因为它要盖满屏幕。
触控尺寸要按屏幕点校验,而不是设计单位。模型里 96 设计单位在 0.48 缩放下只有 46.08pt,刚过 44pt 下限;如果视觉稿把按钮改成 88 单位,就会掉到 42.24pt。这个检查必须写成断言,不能靠肉眼。
无头布局模型:layout-model.ts
模型输入视口、inset 与设计尺寸,输出 Fit、安全矩形、顶栏、底栏和两个按钮矩形。SHAPES 固定四种形状用于回归;换机型时只换参数,不改算法。
// file: src/ui/layout-model.ts
// tier: L1
// verify: node --test test/ui/layout-model.test.ts
export interface Rect {
readonly x: number;
readonly y: number;
readonly width: number;
readonly height: number;
}
export interface Insets {
readonly top: number;
readonly right: number;
readonly bottom: number;
readonly left: number;
}
export interface Viewport {
readonly width: number;
readonly height: number;
readonly insets: Insets;
}
export interface Design {
readonly width: number;
readonly height: number;
}
export interface Shape {
readonly name: string;
readonly viewport: Viewport;
}
export interface Fit {
readonly scale: number;
readonly content: Rect;
readonly visible: Rect;
readonly cropX: number;
readonly cropY: number;
}
export type Anchor =
| "top-left"
| "top-center"
| "top-right"
| "center-left"
| "center"
| "center-right"
| "bottom-left"
| "bottom-center"
| "bottom-right";
export interface HudLayout {
readonly name: string;
readonly fit: Fit;
readonly background: Rect;
readonly safe: Rect;
readonly topBar: Rect;
readonly bottomBar: Rect;
readonly pauseButton: Rect;
readonly primaryButton: Rect;
readonly touchSizePt: number;
}
export const ZERO_INSETS: Insets = { top: 0, right: 0, bottom: 0, left: 0 };
export const DESIGN_750: Design = { width: 750, height: 1334 };
export const TOP_BAR_HEIGHT = 112;
export const BOTTOM_BAR_HEIGHT = 144;
export const TOUCH_TARGET = 96;
export const MIN_TOUCH_PT = 44;
export const SHAPES: readonly Shape[] = [
{
name: "legacy-16x9",
viewport: { width: 360, height: 640, insets: ZERO_INSETS },
},
{
name: "notch-19.5x9",
viewport: { width: 390, height: 844, insets: { top: 47, right: 0, bottom: 34, left: 0 } },
},
{
name: "punch-hole-20x9",
viewport: { width: 360, height: 800, insets: { top: 24, right: 0, bottom: 24, left: 0 } },
},
{
name: "tablet-4x3",
viewport: { width: 768, height: 1024, insets: { top: 0, right: 0, bottom: 20, left: 0 } },
},
];
export function fitCover(view: Viewport, design: Design = DESIGN_750): Fit {
const scale = Math.max(view.width / design.width, view.height / design.height);
const content: Rect = {
x: (view.width - design.width * scale) / 2,
y: (view.height - design.height * scale) / 2,
width: design.width * scale,
height: design.height * scale,
};
return {
scale,
content,
visible: {
x: -content.x / scale,
y: -content.y / scale,
width: view.width / scale,
height: view.height / scale,
},
cropX: content.width - view.width,
cropY: content.height - view.height,
};
}
export function safeRect(view: Viewport, fit: Fit): Rect {
const left = view.insets.left / fit.scale;
const top = view.insets.top / fit.scale;
const right = view.insets.right / fit.scale;
const bottom = view.insets.bottom / fit.scale;
return {
x: fit.visible.x + left,
y: fit.visible.y + top,
width: Math.max(0, fit.visible.width - left - right),
height: Math.max(0, fit.visible.height - top - bottom),
};
}
export function anchorRect(
parent: Rect,
width: number,
height: number,
anchor: Anchor,
offsetX = 0,
offsetY = 0,
): Rect {
let x = parent.x;
let y = parent.y;
if (anchor.endsWith("center")) {
x = parent.x + (parent.width - width) / 2;
} else if (anchor.endsWith("right")) {
x = parent.x + parent.width - width;
}
if (anchor.startsWith("center")) {
y = parent.y + (parent.height - height) / 2;
} else if (anchor.startsWith("bottom")) {
y = parent.y + parent.height - height;
}
return { x: x + offsetX, y: y + offsetY, width, height };
}
export function layoutHud(shape: Shape, design: Design = DESIGN_750): HudLayout {
const fit = fitCover(shape.viewport, design);
const safe = safeRect(shape.viewport, fit);
const topHeight = Math.min(TOP_BAR_HEIGHT, safe.height / 3);
const bottomHeight = Math.min(BOTTOM_BAR_HEIGHT, safe.height / 3);
const topBar: Rect = { x: safe.x, y: safe.y, width: safe.width, height: topHeight };
const bottomBar: Rect = {
x: safe.x,
y: safe.y + safe.height - bottomHeight,
width: safe.width,
height: bottomHeight,
};
return {
name: shape.name,
fit,
background: { x: 0, y: 0, width: shape.viewport.width, height: shape.viewport.height },
safe,
topBar,
bottomBar,
pauseButton: anchorRect(topBar, TOUCH_TARGET, TOUCH_TARGET, "top-right", -8, 0),
primaryButton: anchorRect(bottomBar, TOUCH_TARGET, TOUCH_TARGET, "bottom-center", 0, -12),
touchSizePt: TOUCH_TARGET * fit.scale,
};
}
export function contains(rect: Rect, x: number, y: number): boolean {
return x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height;
}
测试验证的是不变量,而不是某台设备的像素:背景必须等于视口;顶栏与底栏必须完全落在安全矩形内且不重叠;安全矩形必须落在可见设计区域内;每个形状的触控尺寸必须不低于 44pt;刘海屏和打孔屏的顶部栏必须从 inset 换算后的位置开始。这些断言可以在 CI 里跑,不需要真机。
这不是渲染验证
必须把边界说清楚:模型只计算矩形,不证明渲染正确。它不知道字体度量,无法发现「按钮够大但文字被截断」;不知道图片九宫格的拉伸规则,无法发现圆角被拉成椭圆;不知道引擎的裁剪与合批,无法发现 cover 裁切后背景图出现接缝。它也不能替代真机上的触摸热区验证,因为系统手势区、屏幕贴膜和边缘防误触都会改变实际可点范围。
模型能提前拦住的是另一类错误:坐标越界、安全区方向搞反、缩放比例用错、按钮尺寸在窄屏上跌破下限。这类错误占适配问题的大头,而且最适合写成回归测试。渲染与性能的验证仍要回到帧预算与真机验收,模型不能替代。
案例设定里,对照案例《潮汐方舟》是 8–12 分钟战斗,HUD 元素更多,且左右两侧都有技能键;它的 4:3 平板占比更高,因此不能简单复用消消的「顶栏 + 底栏」两段式布局,需要把左右安全区也纳入锚点约束。
与相邻章节的关系
安全区是 UI 栈的几何前提:弹窗与 Toast 的摆放同样要先过安全矩形,见UI 栈、弹窗队列与返回键语义 。形态更复杂的平板、折叠屏与异形屏会在后续设备章节展开,这里先用四类形状覆盖主要分支。如果使用引擎自带的多分辨率方案,可对照Cocos 多分辨率适配 ,但安全区仍需从壳层读取,不能由设计稿推断。
小结
多分辨率适配的核心不是「多准备几套图」,而是把设计坐标、屏幕坐标和安全区三者的换算写清楚。cover 决定缩放与裁切,安全区决定可用区域,锚点决定元素跟随哪条边,测试决定这些关系会不会在下次改版时被破坏。模型不替代真机,但它能把大部分几何错误挡在提交之前。
阅读导航:上一节:7.1 UI 栈、弹窗队列与返回键语义 · 下一节:7.3 触控仲裁与误触防护 。
常见错误与边界
- 把安全区 inset 直接加到设计坐标上。inset 的单位是屏幕点,必须先除以缩放比例。
- 用
contain做全屏游戏 HUD 却不管黑边。黑边区域可能被系统手势或挖孔占用,按钮落进去会点不到。 - 只检查设计稿上的按钮尺寸,不换算成屏幕点。窄屏上 88 设计单位可能只有 42pt,低于 44pt 下限。
- 背景也套安全区,导致刘海两侧或 Home 指示条下方露出底色。背景应铺满视口,内容才受安全区约束。
- 假设所有设备都是竖屏。平板与折叠屏可能横屏或分屏,宽高比会反转,裁切轴也随之改变。
- 把无头布局模型的输出当成渲染验收。它不测量字体、纹理拉伸和 GPU 裁剪,真机检查不能省。
- 只验证一种「典型机型」。至少覆盖无刘海、刘海、打孔、平板四类形状,并把它们固化成回归用例。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。