站内的 Cocos 游戏开发实战 那 55 篇是 Cocos Creator 的产品交付工程(含它的 UI 组件体系、Widget 与 ScrollView 怎么用、节点树怎么挂),跟着它能把一个游戏在某个引擎里做完。本节讲的是与引擎无关的 UI 结构原理:布局约束、层级与遮挡、虚拟滚动、合批条件——这套结构换成任何引擎都成立,甚至换成 Canvas 手写都成立。第 3 章解决的是「设计分辨率怎么映射到屏幕」,本节解决的是「屏幕里的那些框,谁摆在哪、谁盖住谁、长列表怎么只画看得见的」。
布局的三个约束量
任何一个 UI 元素最终都要落到一个整数像素矩形 {x, y, w, h}。要把「设计师说的靠右、离边 4 像素、高 28」变成这四个数,只需要三个量:
- 锚点:元素贴在父容器哪一侧。横轴取
left / center / right / stretch,纵轴取top / middle / bottom / stretch。stretch是「拉满父容器」,其余三个是「对齐到某一侧」。 - 偏移:对齐之后再加的位移,比如靠右再往里缩 4 像素。
- 边距:只有
stretch用得上——两侧各留多少,中间拉满。
三个量都是整数,没有浮点,也就没有「0.1 像素的缝隙」这种玄学问题。这是小游戏 UI 最该守住的口径:所有位置和尺寸都是整数像素,既省得跟抗锯齿较劲,也让「一份布局配置被内核和浏览器原型逐位复现」成为可能。
层级决定谁盖住谁
布局算出的是「框在哪」,但两个框重叠时谁在上面,是另一套东西——层级。绘制顺序按层级升序:层级低的先画、会被层级高的盖住;同层则按配置里的先后顺序稳定排序。于是「弹窗盖住底栏、底栏盖住棋盘」就变成一句排序,而不是靠节点树的挂法碰运气。
有了绘制顺序,点命中也就顺理成章:一个手指点下去,从最上层往下找第一个包含该点的框,那个就是真正接到事件的元素。这解释了小游戏里最常见的一类 bug——「按钮点了没反应」,十有八九是有个透明的大框压在上面。
长列表只画看得见的
背包、排行榜、商店列表动辄几百条,全实例化既费内存又费合批。虚拟滚动的做法是:只保留视口内的条目,外加上下各一条作为缓冲(overscan),滚动时回收出屏的、补齐入屏的。要算的只有两件事——第一条是第几条(起点用整数地板除),到第几条为止(终点用整数上取整)。条目总数 itemCount 与视口高度 viewportH 决定了内容总高与滚动上限。
D 层:把布局写成配置
下面这份 D 层配置落在 config/e11_ui.json:一个设计分辨率、一份滚动参数,以及九个节点——顶栏、返回键、标题、金币徽章、棋盘面板、滚动视图、底栏、主按钮。每个节点只声明「挂在谁下面 + 锚点 + 尺寸 + 偏移 + 边距 + 层级」,位置全靠求解,不写死坐标。
{
"version": 1,
"game": "merge-island",
"note": "UI 布局约束配置:设计分辨率 375×667,节点按锚点(left/center/right/stretch × top/middle/bottom/stretch)+ 偏移 + 边距递归求解最终整数像素矩形。父节点必须先于子节点出现。全程整数像素,禁浮点。",
"designWidth": 375,
"designHeight": 667,
"scroll": { "itemHeight": 96, "itemCount": 50, "overscan": 1 },
"nodes": [
{ "id": "root", "parent": null, "anchorX": "stretch", "anchorY": "stretch", "w": 0, "h": 0, "offsetX": 0, "offsetY": 0, "marginX": 0, "marginY": 0, "layer": 0 },
{ "id": "boardPanel", "parent": "root", "anchorX": "stretch", "anchorY": "stretch", "w": 0, "h": 0, "offsetX": 0, "offsetY": 0, "marginX": 12, "marginY": 64, "layer": 5 },
{ "id": "scrollView", "parent": "boardPanel", "anchorX": "stretch", "anchorY": "stretch", "w": 0, "h": 0, "offsetX": 0, "offsetY": 0, "marginX": 8, "marginY": 8, "layer": 6 },
{ "id": "topbar", "parent": "root", "anchorX": "stretch", "anchorY": "top", "w": 0, "h": 56, "offsetX": 0, "offsetY": 0, "marginX": 8, "marginY": 0, "layer": 10 },
{ "id": "backBtn", "parent": "topbar", "anchorX": "left", "anchorY": "middle", "w": 40, "h": 40, "offsetX": 4, "offsetY": 0, "marginX": 0, "marginY": 0, "layer": 11 },
{ "id": "title", "parent": "topbar", "anchorX": "center", "anchorY": "middle", "w": 160, "h": 24, "offsetX": 0, "offsetY": 0, "marginX": 0, "marginY": 0, "layer": 11 },
{ "id": "coinBadge", "parent": "topbar", "anchorX": "right", "anchorY": "middle", "w": 72, "h": 28, "offsetX": 4, "offsetY": 0, "marginX": 0, "marginY": 0, "layer": 11 },
{ "id": "footerBar", "parent": "root", "anchorX": "stretch", "anchorY": "bottom", "w": 0, "h": 64, "offsetX": 0, "offsetY": 0, "marginX": 0, "marginY": 0, "layer": 20 },
{ "id": "playBtn", "parent": "footerBar", "anchorX": "center", "anchorY": "middle", "w": 200, "h": 48, "offsetX": 0, "offsetY": 0, "marginX": 0, "marginY": 0, "layer": 21 }
]
}
九个节点里没有一个写了绝对坐标——backBtn 只说「贴在 topbar 左侧、垂直居中、离边 4」,它最终落在哪,由 topbar 的矩形决定。父必须先于子出现,这一条在求解器里是硬约束(取不到父矩形就抛错),它保证了配置本身是拓扑有序的。
P 层:布局约束求解器
下面这段是 P 层内核 core/src/e11/layout.ts。axisSpan 是单轴求解的核心——同一个函数吃四个锚点;layoutTree 按配置顺序递归求解;drawOrder 给出绘制顺序;topmostAt 做点命中;visibleRange 算虚拟滚动的可视区间。它不依赖任何引擎的 UI 组件,只算矩形。
// file: core/src/e11/layout.ts
// tier: P
// verify: node --test 'core/test/e11_*.test.ts'
// UI 布局约束求解:锚点(left/center/right/stretch × top/middle/bottom/stretch)+ 偏移 + 边距,递归求整数像素矩形;
// 再给出绘制顺序(层级)、点命中与遮挡、虚拟滚动可视区间。与引擎无关,只算矩形;禁浮点,除法一律 Math.floor。
export interface Rect { readonly x: number; readonly y: number; readonly w: number; readonly h: number }
export interface UiNode {
readonly id: string; readonly parent: string | null; readonly anchorX: string; readonly anchorY: string;
readonly w: number; readonly h: number; readonly offsetX: number; readonly offsetY: number;
readonly marginX: number; readonly marginY: number; readonly layer: number;
}
export interface UiConfig { readonly designWidth: number; readonly designHeight: number; readonly nodes: readonly UiNode[] }
export interface ScrollSpec { readonly itemHeight: number; readonly itemCount: number; readonly overscan: number }
export interface Placed { readonly id: string; readonly rect: Rect; readonly layer: number; readonly order: number }
export interface VisibleRange { readonly first: number; readonly count: number; readonly contentH: number; readonly maxScroll: number }
// 单轴求解:stretch 用两侧对称边距;center/middle 居中后加偏移;right/bottom 从父末端回推;left/top 直接加偏移。
export function axisSpan(parentStart: number, parentLen: number, anchor: string, size: number, offset: number, margin: number): { start: number; len: number } {
if (anchor === "stretch") return { start: parentStart + margin, len: Math.max(0, parentLen - margin * 2) };
if (anchor === "center" || anchor === "middle") return { start: parentStart + Math.floor((parentLen - size) / 2) + offset, len: size };
if (anchor === "right" || anchor === "bottom") return { start: parentStart + parentLen - size - offset, len: size };
return { start: parentStart + offset, len: size };
}
export function resolveNode(parent: Rect, node: UiNode): Rect {
const xs = axisSpan(parent.x, parent.w, node.anchorX, node.w, node.offsetX, node.marginX);
const ys = axisSpan(parent.y, parent.h, node.anchorY, node.h, node.offsetY, node.marginY);
return { x: xs.start, y: ys.start, w: xs.len, h: ys.len };
}
// 按配置顺序求解(父必须先于子出现);parent 为 null 的节点挂在设计分辨率的根矩形上。
export function layoutTree(cfg: UiConfig): Placed[] {
const rects = new Map<string, Rect>(), out: Placed[] = [];
const root: Rect = { x: 0, y: 0, w: cfg.designWidth, h: cfg.designHeight };
cfg.nodes.forEach((n, i) => {
const parent = n.parent === null ? root : rects.get(n.parent);
if (parent === undefined) throw new Error("parent missing: " + n.parent + " for " + n.id);
const rect = resolveNode(parent, n);
rects.set(n.id, rect);
out.push({ id: n.id, rect, layer: n.layer, order: i });
});
return out;
}
export function rectOf(placed: readonly Placed[], id: string): Rect {
const p = placed.find((x) => x.id === id);
if (p === undefined) throw new Error("no node: " + id);
return p.rect;
}
// 绘制顺序:层级升序(低层先画、被高层覆盖),同层按配置顺序稳定排序。
export function drawOrder(placed: readonly Placed[]): Placed[] {
return placed.slice().sort((a, b) => (a.layer - b.layer) || (a.order - b.order));
}
export function contains(r: Rect, x: number, y: number): boolean {
return x >= r.x && x < r.x + r.w && y >= r.y && y < r.y + r.h;
}
// 点命中:从最上层往下找第一个包含该点的节点;命中即被遮挡。
export function topmostAt(placed: readonly Placed[], x: number, y: number): string | null {
const ord = drawOrder(placed);
for (let i = ord.length - 1; i >= 0; i -= 1) if (contains(ord[i].rect, x, y)) return ord[i].id;
return null;
}
// 虚拟滚动:只实例化视口内(含上下各 overscan 条)的条目;上取整用 (a+b-1)/b 避开浮点 ceil。
export function visibleRange(spec: ScrollSpec, viewportH: number, scrollY: number): VisibleRange {
const contentH = spec.itemCount * spec.itemHeight, maxScroll = Math.max(0, contentH - viewportH);
const sy = Math.min(Math.max(0, scrollY), maxScroll);
const first = Math.max(0, Math.floor(sy / spec.itemHeight) - spec.overscan);
const lastEx = Math.min(spec.itemCount, Math.floor((sy + viewportH + spec.itemHeight - 1) / spec.itemHeight) + spec.overscan);
return { first, count: Math.max(0, lastEx - first), contentH, maxScroll };
}
配套的 core/test/e11_layout.test.ts 把九个节点的矩形、绘制顺序、三处点命中、三档滚动位置全部钉死——任何一次改动挪动了布局或可视区间,测试立刻红。跑 node --test 'core/test/e11_*.test.ts',这 11 个用例(含弹窗的 6 个)全过。
W 层:能拖动滚动的页面
下面这段是 W 层构建脚本 web/build_e11.mjs。它把布局内核内联进单文件 HTML,画出九个节点的框(颜色按层级),并带一根滑杆驱动虚拟滚动——拖动时只重画可视区间内的条目。
#!/usr/bin/env node
// file: web/build_e11.mjs
// tier: W
// verify: node web/build_e11.mjs
// 把 core/src/e11/layout.ts 的布局约束求解器打成单文件 HTML:按锚点算出九个 UI 节点的矩形,画出层级顺序与
// 点命中,并用滑杆演示虚拟滚动只实例化可视区间。产物 web/dist/e11.html。
import { readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { bundle, page, writePage, inlineConfig } from "./pack.mjs";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const config = JSON.parse(readFileSync(join(ROOT, "config", "e11_ui.json"), "utf8"));
const js = bundle(ROOT, ["core/src/e11/layout.ts"]);
const script = `const cfg = ${inlineConfig(config)};
/* ---- core bundle: TS -> strip -> inline ---- */
${js}
/* ---- end bundle ---- */
const placed = layoutTree(cfg), order = drawOrder(placed), sv = rectOf(placed, "scrollView");
let scrollY = 0;
const cv = document.getElementById("ui"), ctx = cv.getContext("2d");
const set = (id, v) => { document.getElementById(id).textContent = String(v); };
const COLOR = (L) => L >= 20 ? "#f59e0b" : L >= 10 ? "#38bdf8" : L >= 5 ? "#22c55e" : "#475569";
function render() {
const W = cfg.designWidth, H = cfg.designHeight, vr = visibleRange(cfg.scroll, sv.h, scrollY);
ctx.fillStyle = "#0b1220"; ctx.fillRect(0, 0, W, H);
ctx.fillStyle = "#111c33"; ctx.fillRect(sv.x, sv.y, sv.w, sv.h);
for (let i = vr.first; i < vr.first + vr.count; i += 1) {
const iy = sv.y + i * cfg.scroll.itemHeight - scrollY;
if (iy + cfg.scroll.itemHeight < sv.y || iy > sv.y + sv.h) continue;
ctx.fillStyle = i % 2 === 0 ? "#334155" : "#293548"; ctx.fillRect(sv.x, iy, sv.w, cfg.scroll.itemHeight - 4);
ctx.fillStyle = "#94a3b8"; ctx.font = "12px system-ui"; ctx.fillText("item " + i, sv.x + 8, iy + 20);
}
for (const p of order) { // 按绘制顺序画边框,越后画越靠上
ctx.strokeStyle = COLOR(p.layer); ctx.lineWidth = 1.5;
ctx.strokeRect(p.rect.x + 0.5, p.rect.y + 0.5, p.rect.w - 1, p.rect.h - 1);
ctx.fillStyle = COLOR(p.layer); ctx.font = "11px system-ui";
ctx.fillText(p.id + " L" + p.layer, p.rect.x + 4, p.rect.y + 13);
}
const pb = rectOf(placed, "playBtn");
set("order", order.map((p) => p.id).join(" > "));
set("playBtn", pb.x + "," + pb.y + " " + pb.w + "x" + pb.h);
set("hit", topmostAt(placed, 187, 635) + " / " + topmostAt(placed, 150, 20));
set("range", "first=" + vr.first + " count=" + vr.count);
set("scrollVal", scrollY + " / " + vr.maxScroll);
globalThis.__pebble = {
rects: { topbar: rectOf(placed, "topbar"), backBtn: rectOf(placed, "backBtn"), title: rectOf(placed, "title"),
coinBadge: rectOf(placed, "coinBadge"), footerBar: rectOf(placed, "footerBar"), playBtn: pb, scrollView: sv },
drawOrder: order.map((p) => p.id), hitBtn: topmostAt(placed, 187, 635), hitTitle: topmostAt(placed, 150, 20),
scrollY, visibleFirst: vr.first, visibleCount: vr.count, contentH: vr.contentH, maxScroll: vr.maxScroll, canvasDrawn: true };
}
const slider = document.getElementById("scroll");
slider.max = String(visibleRange(cfg.scroll, sv.h, 0).maxScroll);
slider.addEventListener("input", () => { scrollY = Number(slider.value); render(); });
const go = (y) => { scrollY = y; slider.value = String(y); render(); };
document.getElementById("mid").addEventListener("click", () => go(960));
document.getElementById("top").addEventListener("click", () => go(0));
render();
`;
const body = ` <h1>《合成小岛》· UI 布局、层级与虚拟滚动</h1>
<div class="sub">内核由 core/src/e11/layout.ts 提供:锚点 + 偏移 + 边距求解整数像素矩形,层级决定遮挡,滚动只实例化可视区间</div>
<div class="card"><div class="k">绘制顺序(低→高)</div><div class="big" id="order" style="font-size:14px">-</div></div>
<div class="card row"><div><div class="k">playBtn 矩形</div><div class="big" id="playBtn">-</div></div><div><div class="k">点命中 635 / 20</div><div class="big" id="hit">-</div></div></div>
<div class="card row"><div><div class="k">可视区间</div><div class="big" id="range">-</div></div><div><div class="k">滚动 / 上限</div><div class="big" id="scrollVal">-</div></div></div>
<div class="card"><div class="k">设计分辨率 375×667(越后画越靠上)</div><canvas id="ui" width="375" height="667" style="width:300px;height:auto;image-rendering:pixelated"></canvas></div>
<div class="card"><input id="scroll" type="range" min="0" max="4277" value="0" step="1" style="width:100%"></div>
<div class="card row"><button id="top">滚到顶部</button><button id="mid">滚到中部</button></div>`;
const bytes = writePage(join(ROOT, "web/dist/e11.html"),
page({ title: "《合成小岛》UI 布局原型 · e11", css: "#ui { background: #0b1220; }", body, script }));
console.log(JSON.stringify({ html: "web/dist/e11.html", htmlBytes: bytes, bundleLines: js.split("\n").length }, null, 2));
W 层:无头验收。 web/verify_e11.mjs 用 Playwright 无头打开页面,断言多个元素的最终矩形、绘制顺序与点命中,再拖动真实滑杆改滚动位置并断言可视区间。
#!/usr/bin/env node
// file: web/verify_e11.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_e11.mjs web/dist/e11.html
// W 层验收:Playwright 无头打开页面,断言多个 UI 元素的最终矩形、绘制顺序、点命中;拖真实滑杆改滚动位置
// 并断言可视区间;检查画布真画了像素、页面无报错,并截图。
import { createRequire } from "node:module";
import { pathToFileURL, fileURLToPath } from "node:url";
import { dirname, join, resolve } from "node:path";
import { mkdirSync } from "node:fs";
import assert from "node:assert/strict";
const { chromium } = createRequire(import.meta.url)("playwright");
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const url = pathToFileURL(resolve(ROOT, process.argv[2] ?? "web/dist/e11.html")).href;
mkdirSync(join(ROOT, "out/shots"), { recursive: true });
const browser = await chromium.launch();
const warm = await browser.newPage(); await warm.goto(url); await warm.close(); // 预热:冷启动首个 page 读 canvas 像素恒为空白
const errors = [];
const page = await browser.newPage({ viewport: { width: 760, height: 1100 } });
page.on("pageerror", (e) => errors.push(String(e)));
await page.goto(url);
await page.waitForFunction(() => globalThis.__pebble?.canvasDrawn === true, null, { timeout: 10000 });
const snap = () => page.evaluate(() => globalThis.__pebble);
const s0 = await snap();
assert.deepEqual(s0.rects.topbar, { x: 8, y: 0, w: 359, h: 56 });
assert.deepEqual(s0.rects.backBtn, { x: 12, y: 8, w: 40, h: 40 });
assert.deepEqual(s0.rects.title, { x: 107, y: 16, w: 160, h: 24 });
assert.deepEqual(s0.rects.coinBadge, { x: 291, y: 14, w: 72, h: 28 });
assert.deepEqual(s0.rects.playBtn, { x: 87, y: 611, w: 200, h: 48 });
assert.deepEqual(s0.rects.scrollView, { x: 20, y: 72, w: 335, h: 523 });
assert.deepEqual(s0.drawOrder, ["root", "boardPanel", "scrollView", "topbar", "backBtn", "title", "coinBadge", "footerBar", "playBtn"]);
assert.deepEqual([s0.hitBtn, s0.hitTitle], ["playBtn", "title"]);
assert.deepEqual([s0.scrollY, s0.visibleFirst, s0.visibleCount, s0.maxScroll], [0, 0, 7, 4277]);
await page.click("#mid");
const s1 = await snap();
assert.equal(s1.scrollY, 960);
assert.deepEqual([s1.visibleFirst, s1.visibleCount], [9, 8]);
await page.locator("#scroll").fill("4277");
const s2 = await snap();
assert.deepEqual([s2.scrollY, s2.visibleFirst, s2.visibleCount], [4277, 43, 7]);
await page.click("#top");
assert.equal((await snap()).scrollY, 0);
const painted = await page.evaluate(() => {
const cv = document.getElementById("ui");
const d = cv.getContext("2d").getImageData(0, 0, cv.width, cv.height).data;
let n = 0; for (let i = 0; i < d.length; i += 4) if (d[i + 3] > 0) n += 1;
return n;
});
await page.screenshot({ path: join(ROOT, "out/shots/e11.png"), fullPage: true });
await browser.close();
assert.equal(errors.length, 0, `页面错误: ${errors.join("; ")}`);
assert.ok(painted > 50000, `布局画布应有绘制,实测 ${painted} px`);
console.log(JSON.stringify({ page: "web/dist/e11.html", playBtn: s0.rects.playBtn, scrollView: s0.rects.scrollView,
drawOrder: s0.drawOrder, hit: [s0.hitBtn, s0.hitTitle],
scroll: { top: [s0.scrollY, s0.visibleFirst, s0.visibleCount], mid: [s1.scrollY, s1.visibleFirst, s1.visibleCount],
bottom: [s2.scrollY, s2.visibleFirst, s2.visibleCount] },
canvasPaintedPx: painted, screenshot: "out/shots/e11.png" }, null, 2));
console.log("SMOKE-W11: PASS");
实跑结果(同机相对量级,绝对数值不可移植):九个节点里几个关键的最终矩形是——顶栏 8,0 359×56、返回键 12,8 40×40、标题 107,16 160×24、金币徽章 291,14 72×28、主按钮 87,611 200×48;绘制顺序是 root > boardPanel > scrollView > topbar > backBtn > title > coinBadge > footerBar > playBtn。
| 点 / 滚动 | 输入 | 结果 |
|---|---|---|
| 点命中主按钮 | (187, 635) | playBtn(盖住底栏) |
| 点命中标题 | (150, 20) | title(盖住顶栏) |
| 滚到顶部 | scrollY=0 | first=0 count=7 |
| 滚到中部 | scrollY=960 | first=9 count=8 |
| 滚到底 | scrollY=4277 | first=43 count=7 |
读法:scrollView 高 523、条目高 96,视口里本可容纳 6 条,加上下各一条 overscan 就是 7 条;滚到中部时区间是第 9~16 条,滚到底被 maxScroll=4277 夹住,只剩 7 条。画布绘制 250125 px,SMOKE-W11: PASS,截图落在 out/shots/e11.png。
这一节要记住的三件事
- 布局是「约束 → 求解」,不是「写死坐标」。节点只说锚点、偏移、边距,位置由父容器递归推出来。这样同一份 UI 配置才能在 375×667 和别的分辨率上都不跑偏。
- 层级和布局是两件事。布局回答「框在哪」,层级回答「谁盖住谁」,点命中回答「谁接到事件」。三者分开算,才能一眼看出「按钮点不动」到底是被谁压住了。
- 长列表用虚拟滚动,只画看得见的。可视区间由
itemCount、itemHeight、viewportH、scrollY四个整数决定,上取整用(a+b-1)/b避开浮点,区间要clamp到[0, itemCount]。
必须诚实标注:本节确定的是布局求解与可视区间的算法口径(整数像素、锚点四态、地板除与整数上取整、可复现),不是任何具体引擎的 UI 组件实现——Cocos 的 Widget / ScrollView、Unity 的 RectTransform / ScrollRect 各有各的细节,那部分请对照引擎官方文档与站内引擎专书。
阅读导航:上一节:多平台差异与兼容矩阵 · 下一节:弹窗、提示与状态反馈 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。