《Cocos 游戏开发实战》6.1 UI 层级与多分辨率适配

本节把《方块冲刺》的设计分辨率锁死在 960 × 540,用 Canvas 与 UI 相机建立渲染根,用 Widget 把 HUD 钉在四角、用 SafeArea 躲开刘海与手势条,并给出编辑器里逐项可照做的操作步骤与操作后属性面板应显示的值。

6.1 UI 层级与多分辨率适配

第 5 章把场景流程搭好了,但 Canvas 下还是空的。UI 系统的第一件事不是画按钮,而是确定坐标系:设计稿画在多大的画布上、这块画布怎么映射到从 960×540 到 2532×1170 的各种真实屏幕、以及界面元素被摆到屏幕四角时靠什么保证不越界。

这三个问题在 Cocos Creator 3.8 里分别由 Canvas(渲染根与设计分辨率)、Widget(相对父容器定位)和 SafeArea(刘海与手势条)三个组件承担。这一节把《方块冲刺》的 UI 骨架定下来,后续所有界面都长在这套骨架上。

与站内 game/client/ 那批文章的分工:那些文章把「多分辨率适配」当作概念讲(拉伸、黑边、安全区),本节给的是 Cocos Creator 3.8 里逐项可照做的编辑器操作与运行时代码。

6.1.1 UI 渲染根:Canvas 与 UI 相机

在 3.8 里,所有 UI 节点必须挂在 Canvas 下,Canvas 是 UI 的渲染根。它背后自动带一个正交相机,负责把 UI 层渲染出来。节点树长这样(L3 工程文件,需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现):

{
  "__type__": "cc.Node",
  "_name": "Canvas",
  "_components": [ { "__id__": 3 }, { "__id__": 4 }, { "__id__": 5 } ],
  "_children": [
    { "__id__": 6 },
    { "__id__": 10 },
    { "__id__": 14 }
  ]
}

需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。 打开 assets/scenes/main.scene,Canvas 的 _components 依次是 cc.UITransform、cc.Canvas、cc.Widget;子节点依次是 Camera(UI 相机)、HudLayer、PopupLayer。节点在 _children 里的顺序就是绘制顺序,越靠后越晚画、越盖在上面。所以层级约定是:

层节点内容绘制顺序
背景层BgLayer滚动背景、远景最先(最底)
玩法层GameLayer角色、障碍、金币中
HUD 层HudLayer金币数、距离、暂停键靠上
弹窗层PopupLayer暂停面板、结算、设置最后(最顶)

HUD 与弹窗分两层,是为了让弹窗盖住 HUD 时不用去改 HUD 的节点顺序;PopupLayer 天然在 HudLayer 之后绘制。

6.1.2 设计分辨率:960 × 540 与 SHOW_ALL

设计分辨率是「美术出图时假定的画布尺寸」,block-dash 锁定 960 × 540(16:9)。真实设备几乎没有正好 16:9 的屏幕,中间那层映射策略决定了「画面是留黑边还是被裁」。

编辑器里设置(需 Cocos Creator 3.8 编辑器):菜单 项目 → 项目设置 → 项目数据 → 设计分辨率,填 宽度 960、高度 540,同时勾选 Fit Width 与 Fit Height。同时勾选时引擎采用 ResolutionPolicy.SHOW_ALL——整张设计稿始终完整可见,多余空间留黑边,任何内容都不会被裁掉。

这一步的配置会落到工程文件里(L3 工程文件,需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现):

{
  "__type__": "cc.Settings",
  "general": {
    "designResolution": {
      "width": 960,
      "height": 540,
      "fitWidth": true,
      "fitHeight": true
    }
  }
}

需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。 在编辑器里,这一步对应的是项目设置面板上的两个勾选框,改完保存即写入上面的文件,无需手改 JSON。

为什么横版跑酷也选 SHOW_ALL 而不是更常见的「高度铺满」?因为《方块冲刺》的障碍是按设计坐标摆放的,SHOW_ALL 能保证设计坐标到屏幕坐标的映射是一个统一的正等比缩放,障碍永远不会因为屏幕更窄而被挤到可视区外。代价是非 16:9 屏幕上的黑边——这部分靠背景层用 NO_BORDER 铺满来遮。

策略缩放结果适用
SHOW_ALL取较小缩放设计稿完整可见,留黑边本工程全局锁定
FIXED_HEIGHT高度对齐高度铺满,宽度按比例只想保证纵向构图时
FIXED_WIDTH宽度对齐宽度铺满,高度按比例只想保证横向构图时
NO_BORDER取较大缩放铺满,边缘被裁全屏背景图

运行时也应当显式再设一次,避免编辑器预览与真机不一致:

import { _decorator, Component, view, ResolutionPolicy } from 'cc';
const { ccclass } = _decorator;

@ccclass('ResolutionBootstrap')
export class ResolutionBootstrap extends Component {
  start(): void {
    view.setDesignResolutionSize(960, 540, ResolutionPolicy.SHOW_ALL);
    const visible = view.getVisibleSize();
    console.log('[ResolutionBootstrap] 可见尺寸', visible.width, visible.height);
    console.log('[ResolutionBootstrap] 缩放', view.getScaleX(), view.getScaleY());
  }
}

view.getVisibleSize() 返回的是设计单位下的可见尺寸:SHOW_ALL 下它至少有一边等于 960 或 540,另一边可能更大(黑边区)。做全屏遮罩、渐变背景时用它取尺寸,比硬编码 960×540 更稳。

6.1.3 用 Widget 钉住 HUD 四角

HUD 元素要「永远贴在屏幕某个角」,这正是 Widget 组件做的事。编辑器步骤(需 Cocos Creator 3.8 编辑器):

  1. 层级管理器右键 Canvas → 创建 → UI → Node,重命名为 HudLayer。
  2. 选中 HudLayer,属性面板 → 添加组件 → UI → Widget。
  3. 在 Widget 组件里勾选 Top 与 Left,Top 填 16、Left 填 24,Align Mode 选 ON_WINDOW_RESIZE。
  4. 在 HudLayer 下新建 CoinLabel,加 Label 组件,String 填 0,Font Size 填 28。

操作后属性面板应显示:HudLayer 的 UITransform 里 Content Size 是 (100, 100)(占位值,由 Widget 在运行时重算),Widget 的 Top = 16、Left = 24、Align Mode = ON_WINDOW_RESIZE。

同样的效果也可以用代码设置,方便批量初始化:

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

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

  start(): void {
    this.anchor(this.coinLabel, { top: 16, left: 24 });
    this.anchor(this.pauseButton, { top: 16, right: 24 });
  }

  private anchor(
    node: Node | null,
    edge: { top?: number; left?: number; right?: number; bottom?: number },
  ): void {
    if (node === null) return;
    const w = node.getComponent(Widget) ?? node.addComponent(Widget);
    if (edge.top !== undefined) { w.isAlignTop = true; w.top = edge.top; }
    if (edge.bottom !== undefined) { w.isAlignBottom = true; w.bottom = edge.bottom; }
    if (edge.left !== undefined) { w.isAlignLeft = true; w.left = edge.left; }
    if (edge.right !== undefined) { w.isAlignRight = true; w.right = edge.right; }
    w.alignMode = Widget.AlignMode.ON_WINDOW_RESIZE;
    w.updateAlignment();
  }
}

Widget.AlignMode 有三档,含义差别很大:ONCE 只在首次激活时对齐一次(脚本或动画之后可以接管位置),ON_WINDOW_RESIZE 在窗口尺寸变化时重算(推荐值),ALWAYS 每帧重算。不要给几百个 HUD 元素都用 ALWAYS——它会让每帧的布局计算变成帧预算里的固定开销。updateAlignment() 是手动触发一次对齐,在代码里改完 top/left 后立即生效。

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

现代手机的有效显示区不是矩形。刘海、灵动岛、圆角、底部手势条都会盖住内容。SafeArea 组件挂在 UI 根节点上后,运行时会自动把该节点收缩进安全区。

编辑器步骤(需 Cocos Creator 3.8 编辑器):

  1. 选中 Canvas 下的 HudLayer。
  2. 属性面板 → 添加组件 → UI → SafeArea。
  3. 确认 SafeArea 的 Symmetric 不勾选(四边独立收缩)。

SafeArea 会把节点的 UITransform 尺寸与位置改到安全区范围内,因此它必须和 Widget 配合:SafeArea 负责「往内缩」,Widget 负责「缩完之后贴到哪条边」。顺序上,SafeArea 应在 Widget 之后生效,实际做法是把 SafeArea 挂在外层容器、Widget 挂在里层的具体元素上。运行时也可以动态检查:

import { _decorator, Component, view, screen, sys } from 'cc';
const { ccclass } = _decorator;

@ccclass('SafeAreaProbe')
export class SafeAreaProbe extends Component {
  start(): void {
    const frame = view.getFrameSize();
    const pixel = view.getVisibleSizeInPixel();
    console.log('[SafeAreaProbe] 帧尺寸', frame.width, frame.height);
    console.log('[SafeAreaProbe] 像素可见', pixel.width, pixel.height);
    console.log('[SafeAreaProbe] 设备像素比', screen.devicePixelRatio);
    if (sys.isMobile && sys.platform === sys.Platform.WECHAT_GAME) {
      // 小游戏端的安全区由平台接口给出,需在 6.1.5 的换算里折算进设计空间
    }
  }
}

安全区上报的是物理像素,而布局用的是设计像素,两者相差一个缩放系数。view.getScaleX() / view.getScaleY() 就是这个系数;把它们除掉,才能得到设计空间里的内边距。

6.1.5 分辨率无关的换算:纯逻辑,可以单测

「把物理像素的安全区内边距换算到设计空间」这件事与引擎无关,抽出来就能单测。这一段是本节唯一能在命令行复现的部分:

// src/core/ui-scale.ts —— 与引擎无关,可直接单测
export interface Insets {
  readonly top: number;
  readonly bottom: number;
  readonly left: number;
  readonly right: number;
}

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

/**
 * 把物理像素的四边内边距除以缩放系数,回到设计空间。
 * scale 为 0 或负数时按 1 处理,避免除零把整个布局算成 Infinity。
 */
export function insetsToDesign(insets: Insets, scale: number): Insets {
  const s = scale > 0 ? scale : 1;
  return {
    top: insets.top / s,
    bottom: insets.bottom / s,
    left: insets.left / s,
    right: insets.right / s,
  };
}

/** 在设计空间里扣掉四边安全区,得到可用矩形 */
export function safeRect(design: Rect, insets: Insets, scale: number): Rect {
  const d = insetsToDesign(insets, scale);
  return {
    x: design.x + d.left,
    y: design.y + d.top,
    width: design.width - d.left - d.right,
    height: design.height - d.top - d.bottom,
  };
}

配一个单测,把「除零要兜底」和「四边独立收缩」两条钉住:

// tests/ui-scale.test.ts
import { describe, it, expect } from 'vitest';
import { insetsToDesign, safeRect } from '../src/core/ui-scale';

describe('ui-scale', () => {
  it('物理像素除以缩放回到设计空间', () => {
    const d = insetsToDesign({ top: 88, bottom: 68, left: 0, right: 0 }, 2);
    expect(d.top).toBe(44);
    expect(d.bottom).toBe(34);
  });

  it('scale 为 0 时不产生 Infinity', () => {
    const d = insetsToDesign({ top: 10, bottom: 10, left: 10, right: 10 }, 0);
    expect(d.top).toBe(10);
  });

  it('安全区从设计矩形四边扣掉', () => {
    const r = safeRect(
      { x: 0, y: 0, width: 960, height: 540 },
      { top: 40, bottom: 20, left: 0, right: 0 },
      1,
    );
    expect(r.y).toBe(40);
    expect(r.height).toBe(480);
  });
});

6.1.6 工程收口

这一节做完,工程里多了这些确定的东西:

  • settings/v2/packages/project.json:设计分辨率 960 × 540,fitWidth 与 fitHeight 均为 true(SHOW_ALL)。
  • assets/scenes/main.scene:Canvas 下按 Camera / BgLayer / GameLayer / HudLayer / PopupLayer 顺序组织。
  • assets/scripts/ui/resolution-bootstrap.ts:启动时显式设定分辨率策略。
  • assets/scripts/ui/hud-anchors.ts:HUD 元素四角定位。
  • assets/scripts/core/ui-scale.ts:安全区换算,纯逻辑可单测。

多分辨率回归清单(每次改 UI 都要过一遍):在编辑器预览里依次切 960×540、1334×750、2532×1170、1170×2532 四档,检查 HUD 四角元素是否贴边不越界、弹窗是否居中、安全区在竖屏下是否生效。下一节把 PopupLayer 与 HudLayer 用起来,先做最耗性能的那类界面:长列表。

阅读导航:上一节:目录 · 下一节:6.2 列表与滚动容器 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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