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);
| 能力 | mini2d | Cocos Creator 3.8 |
|---|---|---|
| 尺寸与锚点 | Size + Anchor | UITransform(setContentSize / setAnchorPoint) |
| 相对父容器对齐 | layoutInParent | Widget(isAlignTop / top …) |
| 线性排列 | arrange | Layout(type / spacingX / padding) |
| 设计分辨率 | computeScale | view.setDesignResolutionSize + ResolutionPolicy |
| 安全区 | applySafeArea | SafeArea 组件 |
| UI 渲染根 | UIRoot 手动组织 | Canvas 组件 |
有一处差异要注意:mini2d 的布局是每次需要时显式调用的纯函数,而 Cocos 的 Widget / Layout 是挂在节点上的组件,由引擎在合适的时机回调。组件化的好处是编辑期可视化调整,代价是「什么时候重算」变成了引擎的调度问题——alignMode 设成 ALWAYS 很方便,但一个界面里几百个 Widget 每帧重算就是白白的开销。
下一节我们把这一节的布局能力用在滚动列表上:当列表有一千项、屏幕只放得下十几项时,怎么只渲染看得见的部分,并让滚动手感接近原生。
阅读导航:上一节:目录 · 下一节:11.2 列表虚拟化与滚动性能 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。