《移动游戏产品的架构·客户端与移动运行时》7.2 多分辨率、刘海屏与安全区

刘海屏毁掉的第一版 HUD,往往不是按钮太小,而是顶部状态栏压住了暂停键。本节把逻辑分辨率、cover 缩放、安全区映射与九宫格锚点写成无头布局模型,用四种屏幕形状验证矩形不越界、触控目标不低于 44pt,并明确它只是布局计算,不是渲染验证。

事故:HUD 在三种手机上各坏一次

《潮汐消消》早期按 750×1334 的视觉稿做 HUD,所有坐标都写死。第一轮外测收到三类反馈:刘海屏手机上,左上角的体力条被状态栏遮住;带 Home 指示条的手机上,底部「开始」按钮的一部分落在系统手势区,点击经常被系统截走;平板 4:3 上,背景只铺到中间一条,两侧露出黑边。

三个问题的共同点不是「适配没做」,而是把一套设计坐标当成了屏幕坐标。设计稿只定义相对关系;屏幕尺寸、显示缩放和安全区由设备决定。把它们混在一起,任何一处写死都会在另一类设备上变成可见缺陷。

本节只做布局计算:给定视口、安全区与设计尺寸,算出背景、安全矩形、顶部栏、底部栏和按钮矩形。它是无头模型,不解码图片、不测量字体、不调用 GPU,因此不能用它宣称「渲染正确」。真实设备上仍要检查文字截断、九宫格拉伸与触摸热区。

逻辑分辨率是一套映射,不是一种分辨率

移动端游戏通常选一个设计尺寸,再用一个缩放策略映射到视口。常见策略有两种:contain 保证设计稿完整可见,代价是黑边;cover 保证屏幕铺满,代价是裁掉一条边。全屏游戏几乎都选 cover,因为黑边比裁切更显眼。

策略缩放公式结果适用
containmin(vw/dw, vh/dh)完整设计稿,可能上下或左右留黑边工具页、过场动画
covermax(vw/dw, vh/dh)铺满视口,裁掉较长的一轴游戏 HUD、全屏背景

模型采用 cover:设计尺寸 750×1334,缩放比例是宽高比例的较大值。缩放只决定「一个设计单位对应多少屏幕点」,不决定安全区;安全区来自设备的显示内边距,必须单独映射。

这里有一个容易混淆的换算:安全区 inset 的单位是屏幕点,不是设计单位。把 47pt 的刘海直接加到设计坐标上,在 0.63 缩放下会多留近一倍空间。正确做法是先除以缩放比例,再叠加到可见设计区域的原点上。

安全区是显示坐标,不是设计坐标

安全区的定义是「系统 UI 或硬件开孔占用的屏幕边缘」。它有三个来源:状态栏与刘海、底部 Home 指示条、以及折叠屏或平板的圆角与铰链。三者都只影响显示,不影响设计稿本身。

形状视口(pt)上/下 inset(pt)缩放横向裁切(pt)纵向裁切(pt)安全区顶部(设计单位)
legacy-16x9360×6400 / 00.480000.320.33
notch-19.5x9390×84447 / 340.632784.51074.29
punch-hole-20x9360×80024 / 240.599789.78040.02
tablet-4x3768×10240 / 201.02400342.02167.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 裁剪,真机检查不能省。
  • 只验证一种「典型机型」。至少覆盖无刘海、刘海、打孔、平板四类形状,并把它们固化成回归用例。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《移动游戏产品的架构·商业化与发行工程》附录 C 商业化与发行评审清单
  2. 《移动游戏产品的架构·商业化与发行工程》附录 B 双案例商业化事实表
  3. 《移动游戏产品的架构·商业化与发行工程》附录 A 商业化与发行术语速查