《Cocos 游戏开发入门》11.1 布局系统:锚点、对齐与适配

本节给 mini2d 补上 UI 布局层:用归一化锚点把控件钉在父容器的任意相对位置,做出百分比布局与横竖排列;再处理设计分辨率、等比缩放与刘海屏安全区。末节对照 Cocos Creator 3.8 的 Widget、Layout、UITransform、Canvas、view、ResolutionPolicy 与 SafeArea。

11.1 布局系统:锚点、对齐与适配

到第 10 章为止,《方块冲刺》已经有了渲染、时间、输入、物理、动画、音频和资源加载,但屏幕上除了游戏世界本身,还没有任何「界面」。《方块冲刺》要显示金币数、距离、暂停按钮、结算面板,这些东西必须放在一个稳定的坐标系里,而不是靠手写像素坐标。

手写坐标在单一设备上没问题,一旦换机型就碎。设计稿画在 960×640 上,iPhone 的逻辑分辨率可能是 1170×2532,竖屏安卓平板又可能是 1600×2560;同一个「右上角的暂停按钮」,在窄屏上会压到中间,在宽屏上会飘到看不见的地方。UI 布局要解决的就是这件事:用相对关系描述位置,让引擎在运行时把它算成绝对坐标。

本节把 mini2d 推进到「有 UI 层」这一步:新增 src/ui/layout.ts,给出锚点、九宫格对齐、线性排列、分辨率缩放与安全区五块能力,并配一组 vitest 单测。下一节会拿它去实现一个只渲染可视区的虚拟列表。

11.1.1 锚点:把「哪个点」对齐到「哪儿」

锚点(anchor)是 UI 布局的第一个概念。它是一个归一化坐标,两个分量都在 [0, 1]:(0, 0) 是左上角,(0.5, 0.5) 是中心,(1, 1) 是右下角。锚点同时出现在两个地方,这一点初学者最容易混:

  • 作为父容器内的定位点,它决定子节点被摆到父容器的哪个相对位置;
  • 作为子节点自身的参考点,它决定子节点的哪个点在参与定位。

mini2d 沿用第 2 章的 Rect 与 Size,并采用 y 向下的屏幕坐标系(Canvas2D 的默认方向)。

// src/ui/layout.ts —— mini2d UI 布局核心
export interface Size {
  width: number;
  height: number;
}

export interface Rect {
  x: number;
  y: number;
  width: number;
  height: number;
}

/** 锚点:父容器内的归一化坐标,两个分量都在 [0, 1] */
export interface Anchor {
  x: number;
  y: number;
}

/**
 * 把尺寸为 size 的子矩形按 anchor 摆进父矩形 parent。
 * 返回子矩形左上角坐标(mini2d 采用 y 向下的屏幕坐标系)。
 */
export function placeWithAnchor(
  parent: Rect,
  size: Size,
  anchor: Anchor,
): { x: number; y: number } {
  // 第一步:锚点落在父容器中的绝对位置
  const targetX = parent.x + anchor.x * parent.width;
  const targetY = parent.y + anchor.y * parent.height;
  // 第二步:子矩形自身的锚点按同一比例落在自身内,反推左上角
  return {
    x: targetX - anchor.x * size.width,
    y: targetY - anchor.y * size.height,
  };
}

代入验算一下。父容器 960×640,子控件 100×40,锚点取 (1, 0):targetX = 960,targetY = 0,返回 (860, 0)——子控件的右上角正好贴在父容器右上角。若锚点取 (0, 0),返回 (0, 0),左上角贴合。锚点是 (0.5, 0.5) 时子控件居中,且父容器尺寸变化时子控件自动跟随,这正是「相对定位」的意义。

11.1.2 九宫格对齐与百分比布局

实际 UI 里最常用的锚点只有九个,把它们做成查表,写界面时就不必每次心算 0.5。

export type Align9 =
  | 'top-left' | 'top' | 'top-right'
  | 'left' | 'center' | 'right'
  | 'bottom-left' | 'bottom' | 'bottom-right';

const ANCHOR_TABLE: Record<Align9, Anchor> = {
  'top-left': { x: 0, y: 0 },
  top: { x: 0.5, y: 0 },
  'top-right': { x: 1, y: 0 },
  left: { x: 0, y: 0.5 },
  center: { x: 0.5, y: 0.5 },
  right: { x: 1, y: 0.5 },
  'bottom-left': { x: 0, y: 1 },
  bottom: { x: 0.5, y: 1 },
  'bottom-right': { x: 1, y: 1 },
};

/**
 * 在父容器内按九宫格对齐,margin 是四周留白。
 * 留白通过先收缩父矩形实现,因此子控件永远不会压到父容器边缘。
 */
export function layoutInParent(
  parent: Rect,
  size: Size,
  align: Align9,
  margin = 0,
): { x: number; y: number } {
  const inner: Rect = {
    x: parent.x + margin,
    y: parent.y + margin,
    width: parent.width - margin * 2,
    height: parent.height - margin * 2,
  };
  return placeWithAnchor(inner, size, ANCHOR_TABLE[align]);
}

注意 layoutInParent 并没有把留白直接加在结果上,而是先收缩父矩形再对齐。两者的区别在右下角:bottom-right 加 margin 时,先收缩父矩形能让留白留在外侧,而直接加偏移会把留白留在内侧,两者在贴边控件上会差出一倍 margin。

11.1.3 水平与垂直排列

一排金币图标、一列道具槽,都需要「按主轴依次排开」。这里先实现最朴素的线性排列,把偏移计算和排列解耦——偏移由调用方按内容总长算好,函数本身只管游标推进。

export type Direction = 'row' | 'column';

export interface ArrangeOptions {
  direction: Direction;
  spacing: number; // 相邻两项的间距
  offset: number; // 主轴起点偏移;居中时传 (容器长 - 内容长) / 2
}

export function arrange(
  sizes: readonly Size[],
  opts: ArrangeOptions,
): { x: number; y: number }[] {
  const out: { x: number; y: number }[] = [];
  let cursor = opts.offset;
  for (const s of sizes) {
    if (opts.direction === 'row') {
      out.push({ x: cursor, y: 0 });
      cursor += s.width + opts.spacing;
    } else {
      out.push({ x: 0, y: cursor });
      cursor += s.height + opts.spacing;
    }
  }
  return out;
}

arrange 只负责推进游标,不关心整体位置——offset 交给调用方,让「左对齐」「居中」这类策略留在上层。第 11.2 节的虚拟列表会复用同一套游标逻辑来定位可见项。

11.1.4 设计分辨率与等比缩放

设计分辨率是「美术画图时假定的屏幕尺寸」。运行时真实分辨率千差万别,中间要有一层缩放策略。mini2d 支持五种,和主流引擎一致。

export type ScaleMode =
  | 'exactFit'
  | 'noBorder'
  | 'showAll'
  | 'fixedHeight'
  | 'fixedWidth';

export function computeScale(
  design: Size,
  frame: Size,
  mode: ScaleMode,
): { scaleX: number; scaleY: number } {
  const sx = frame.width / design.width;
  const sy = frame.height / design.height;

  if (mode === 'exactFit') return { scaleX: sx, scaleY: sy };
  if (mode === 'noBorder') {
    const s = Math.max(sx, sy);
    return { scaleX: s, scaleY: s };
  }
  if (mode === 'showAll') {
    const s = Math.min(sx, sy);
    return { scaleX: s, scaleY: s };
  }
  if (mode === 'fixedHeight') return { scaleX: sy, scaleY: sy };
  return { scaleX: sx, scaleY: sx };
}
模式取值后果适用
exactFit非等比,拉伸到满屏画面变形几乎不用
noBorder取较大缩放铺满屏幕,但边缘被裁背景全屏图
showAll取较小缩放整张设计稿可见,留黑边过场、结算面板
fixedHeight高度对齐高度满,宽度按比例竖版跑酷(《方块冲刺》)
fixedWidth宽度对齐宽度满,高度按比例横版 HUD

《方块冲刺》是横版跑酷,主玩法画面按 fixedHeight 适配:高度永远铺满,宽度按比例伸缩,超出的部分由相机视口裁掉(第 2.3 节已经做过相机裁剪)。这样跳跃高度、地面位置在竖直方向上永远一致,手感不会因机型而变。

11.1.5 安全区:刘海、圆角与手势条

现代手机屏幕不是矩形可用区。刘海、灵动岛、圆角、底部手势条都会盖住内容。设备通过 env(safe-area-inset-*) 之类的接口上报四边内边距,布局层要把它换算进设计空间并裁掉。

export interface Insets {
  top: number;
  bottom: number;
  left: number;
  right: number;
}

/**
 * 在帧矩形 frame 上扣掉四边安全区,得到可用矩形。
 * insets 通常是物理像素,先除以缩放系数回到设计空间。
 */
export function applySafeArea(
  frame: Rect,
  insets: Insets,
  scale: number,
): Rect {
  const top = insets.top / scale;
  const bottom = insets.bottom / scale;
  const left = insets.left / scale;
  const right = insets.right / scale;
  return {
    x: frame.x + left,
    y: frame.y + top,
    width: frame.width - left - right,
    height: frame.height - top - bottom,
  };
}

安全区必须在缩放之后处理:设备上报的是物理像素,布局用的是设计像素,两者相差一个 scale。把除法放进函数,是为了让调用点只关心「帧矩形 + 原始 insets」。

11.1.6 单元测试

布局是纯计算,非常适合单测——不需要 canvas,也不需要浏览器。

// tests/ui-layout.test.ts
import { describe, it, expect } from 'vitest';
import {
  placeWithAnchor,
  computeScale,
  applySafeArea,
} from '../src/ui/layout';

describe('layout', () => {
  it('锚点把控件钉在父容器右上角', () => {
    const p = placeWithAnchor(
      { x: 0, y: 0, width: 960, height: 640 },
      { width: 100, height: 40 },
      { x: 1, y: 0 },
    );
    expect(p).toEqual({ x: 860, y: 0 });
  });

  it('showAll 取较小缩放', () => {
    const s = computeScale(
      { width: 960, height: 640 },
      { width: 1170, height: 2532 },
      'showAll',
    );
    expect(s.scaleX).toBeCloseTo(1170 / 960);
    expect(s.scaleX).toBe(s.scaleY);
  });

  it('安全区在 y 向下坐标系里裁掉顶部刘海', () => {
    const safe = applySafeArea(
      { x: 0, y: 0, width: 960, height: 640 },
      { top: 88, bottom: 68, left: 0, right: 0 },
      1,
    );
    expect(safe.y).toBe(88);
    expect(safe.height).toBe(640 - 88 - 68);
  });
});

第三个用例值得留意:showAll 在 1170×2532 的屏幕上只缩放到 1170/960 ≈ 1.22,而非 2532/640 ≈ 3.96。若反过来取较大值,整张设计稿会被放大到宽 3796,两侧内容直接飞出屏幕。showAll 取小、noBorder 取大,这条口诀能省很多调试时间。

11.1.7 Cocos 对照:Widget、Layout 与 view

在 Cocos Creator 3.8 里,上面每一块能力都有一个现成组件。UI 节点挂在 Canvas 下,Canvas 负责建立 UI 渲染根与设计分辨率。

UITransform 承担 mini2d 里 Size + Anchor 的角色:它保存节点尺寸与锚点,并提供 setContentSize、setAnchorPoint、getBoundingBox 等方法。

import { _decorator, Component, Node, Widget, UITransform, view, ResolutionPolicy } from 'cc';
const { ccclass, property } = _decorator;

@ccclass('HudRoot')
export class HudRoot extends Component {
  @property(Node)
  coinLabel: Node | null = null;

  start(): void {
    // 设计分辨率 960×640,按宽度等比缩放,高度方向超出部分可裁剪
    view.setDesignResolutionSize(960, 640, ResolutionPolicy.FIXED_WIDTH);
    const visible = view.getVisibleSize();

    const ui = this.node.getComponent(UITransform);
    ui?.setContentSize(visible.width, visible.height);
  }

  alignCoinLabel(): void {
    const label = this.coinLabel;
    if (label === null) return;
    const widget = label.addComponent(Widget);
    widget.isAlignTop = true;
    widget.isAlignRight = true;
    widget.top = 24;
    widget.right = 24;
    widget.alignMode = Widget.AlignMode.ON_WINDOW_RESIZE;
  }
}

Widget 就是 mini2d 的 layoutInParent 的组件化版本:isAlignTop / isAlignRight 打开哪个方向,top / right 就是那个方向的边距,alignMode 决定何时重新对齐(ONCE 只对齐一次,ON_WINDOW_RESIZE 在窗口变化时重算,ALWAYS 每帧都算)。Layout 对应 arrange,type 取 HORIZONTAL / VERTICAL / GRID,resizeMode 决定是撑大容器(CONTAINER)还是反过来调整子节点(CHILDREN)。

刘海屏由 SafeArea 组件处理,直接挂到 HUD 根节点上即可,运行时会自动把节点收缩进安全区:

// 挂在 HUD 根节点上;编辑器里还需在 Canvas 上勾选 Fit Height / Fit Width
this.node.addComponent(SafeArea);
能力mini2dCocos Creator 3.8
尺寸与锚点Size + AnchorUITransform(setContentSize / setAnchorPoint)
相对父容器对齐layoutInParentWidget(isAlignTop / top …)
线性排列arrangeLayout(type / spacingX / padding)
设计分辨率computeScaleview.setDesignResolutionSize + ResolutionPolicy
安全区applySafeAreaSafeArea 组件
UI 渲染根UIRoot 手动组织Canvas 组件

有一处差异要注意:mini2d 的布局是每次需要时显式调用的纯函数,而 Cocos 的 Widget / Layout 是挂在节点上的组件,由引擎在合适的时机回调。组件化的好处是编辑期可视化调整,代价是「什么时候重算」变成了引擎的调度问题——alignMode 设成 ALWAYS 很方便,但一个界面里几百个 Widget 每帧重算就是白白的开销。

下一节我们把这一节的布局能力用在滚动列表上:当列表有一千项、屏幕只放得下十几项时,怎么只渲染看得见的部分,并让滚动手感接近原生。

阅读导航:上一节:目录 · 下一节:11.2 列表虚拟化与滚动性能 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画