站内的 Cocos 游戏开发实战 那 55 篇讲的是在 Cocos Creator 里怎么摆弹窗预制体、怎么接按钮事件,那是产品交付工程;本节讲的是与引擎无关的弹窗结构原理:它为什么必须是一个栈、遮罩怎么决定点击去哪、重复打开怎么去重、超时怎么自动关——这套结构换成任何引擎都成立。上一节把界面拆成「框 + 层级 + 滚动」,本节把这套层级用在最容易出乱子的地方:会一层层叠起来的弹窗与提示。
弹窗不是「一个框」,是「一个栈」
新手写弹窗最常见的写法是「点一下、new 一个、挂上去」,结果就是——奖励弹窗还没关,又点开了设置,两个框叠在一起谁也不知道该关谁。正确的模型是栈:每个弹窗打开时压栈、关闭时出栈,栈顶永远是最新的那个。栈里每一项都带着自己的层级(layer,越大越靠上)和是否模态(modal)。
有了栈,三个平时最容易出 bug 的问题各有一句话的答案:
- 谁在上面?层级最大的那个;同层按压栈先后。
- 关掉的是哪个?默认关栈顶;要点遮罩关掉「最上面那个模态」而不是「最上面那个框」——因为 Toast 可能浮在模态之上。
- 会不会越堆越多?靠去重键(
dedupeKey)拦住。
遮罩与点击穿透
模态弹窗打开时要铺一层遮罩挡住下面的界面,这层遮罩不只用来变暗,它决定了点击去哪:一个点落在底层的按钮上,如果它上方存在一个层级更高、且是模态的弹窗,这个点击就被拦截、不会穿到下面。用一句话判定就是——「存在 mask 为真且 layer 大于目标层级的弹窗,则拦截」。这条规则能一次说清小游戏里两类经典 bug:一是「弹窗下面那层按钮还能被点到」(遮罩没拦住),二是「弹窗本身点不动」(弹窗自己被上层的透明遮罩压住了)。遮罩的层级 = 最上层模态的层级减一,这样它盖住所有更低的弹窗和底层界面,却不会盖住它自己所属的那个模态。
去重与超时
两件事必须由栈统一管,不能交给各个调用点:
- 去重:同一个
dedupeKey已经在栈里,就拒绝再开。奖励结算这种「可能被多条逻辑同时触发」的弹窗,靠这一条防住重复弹出。 - 超时:
autoCloseMs大于 0 的弹窗,过了这段时间自动关闭——Toast 与加载提示都用它。时间是整数毫秒,不依赖任何真实时钟,所以可以确定性回放。
D 层:把弹窗写成配置
下面这份 D 层配置落在 config/e11_popup.json:五个弹窗规格(奖励、设置、加载、两个提示),每个只声明层级、是否模态、超时、去重键;scenario 给出底层层级与一次「推进」的步长。
{
"version": 1,
"game": "merge-island",
"note": "弹窗栈配置:layer 越大越靠上;modal 决定遮罩与点击拦截;autoCloseMs 为 0 表示手动关闭;同 dedupeKey 已在栈中则拒绝再开。整数毫秒,零随机,可确定性回放。",
"scenario": { "baseLayer": 0, "advanceMs": 1600 },
"specs": [
{ "id": "reward", "layer": 100, "modal": true, "autoCloseMs": 0, "dedupeKey": "reward" },
{ "id": "settings", "layer": 120, "modal": true, "autoCloseMs": 0, "dedupeKey": "settings" },
{ "id": "loading", "layer": 200, "modal": true, "autoCloseMs": 5000, "dedupeKey": "loading" },
{ "id": "toastA", "layer": 300, "modal": false, "autoCloseMs": 1500, "dedupeKey": "toast" },
{ "id": "toastB", "layer": 300, "modal": false, "autoCloseMs": 1500, "dedupeKey": "toast" }
]
}
P 层:弹窗栈内核
下面这段是 P 层内核 core/src/e11/popup.ts。open 做去重后压栈,closeTop / closeById / closeTopModal 覆盖三种关闭场景,tick 处理超时,clickBlocked 就是那条穿透判据。全程整数毫秒、零随机。
// file: core/src/e11/popup.ts
// tier: P
// verify: node --test 'core/test/e11_*.test.ts'
// 弹窗栈与遮罩:按层级压栈、同 dedupeKey 去重、超时自动关闭、模态弹窗拦截点击穿透、点遮罩关最上层模态。
// 纯逻辑,不碰渲染;时间为整数毫秒,零随机(可确定性回放)。
export interface PopupSpec { readonly id: string; readonly layer: number; readonly modal: boolean; readonly autoCloseMs: number; readonly dedupeKey: string }
export interface OpenPopup { readonly id: string; readonly spec: PopupSpec; readonly openedAtMs: number; readonly mask: boolean }
export interface PopupStack { readonly items: OpenPopup[]; readonly log: string[] }
export interface OpenResult { readonly ok: boolean; readonly reason: string; readonly id: string }
export function createStack(): PopupStack { return { items: [], log: [] }; }
// 打开:同 dedupeKey 已在栈中则拒绝(队列去重);否则按 spec.layer 压栈。
export function open(stack: PopupStack, spec: PopupSpec, nowMs: number): OpenResult {
if (stack.items.some((it) => it.spec.dedupeKey === spec.dedupeKey)) return { ok: false, reason: "dupe:" + spec.dedupeKey, id: spec.id };
stack.items.push({ id: spec.id, spec, openedAtMs: nowMs, mask: spec.modal });
stack.log.push("open:" + spec.id + "@" + nowMs);
return { ok: true, reason: "ok", id: spec.id };
}
export function top(stack: PopupStack): OpenPopup | null { return stack.items.length ? stack.items[stack.items.length - 1] : null; }
export function closeTop(stack: PopupStack, nowMs: number): OpenResult {
const t = top(stack);
if (t === null) return { ok: false, reason: "empty", id: "" };
stack.items.pop();
stack.log.push("close:" + t.id + "@" + nowMs);
return { ok: true, reason: "ok", id: t.id };
}
// 按 id 关闭(栈中任意位置,不只是栈顶)。
export function closeById(stack: PopupStack, id: string, nowMs: number): OpenResult {
const i = stack.items.findIndex((it) => it.id === id);
if (i < 0) return { ok: false, reason: "absent:" + id, id };
stack.items.splice(i, 1);
stack.log.push("close:" + id + "@" + nowMs);
return { ok: true, reason: "ok", id };
}
// 关闭最上层模态弹窗(点遮罩的标准行为):非模态的 Toast 不受影响。
export function closeTopModal(stack: PopupStack, nowMs: number): OpenResult {
let idx = -1, best = -1;
for (let i = 0; i < stack.items.length; i += 1) {
const it = stack.items[i];
if (it.mask && it.spec.layer >= best) { best = it.spec.layer; idx = i; }
}
if (idx < 0) return { ok: false, reason: "no-modal", id: "" };
const id = stack.items[idx].id;
stack.items.splice(idx, 1);
stack.log.push("close:" + id + "@" + nowMs);
return { ok: true, reason: "ok", id };
}
// 超时自动关闭:autoCloseMs>0 且已到期的一律移除,返回被关掉的 id(按栈序)。
export function tick(stack: PopupStack, nowMs: number): string[] {
const closed: string[] = [];
for (let i = stack.items.length - 1; i >= 0; i -= 1) {
const it = stack.items[i];
if (it.spec.autoCloseMs > 0 && nowMs - it.openedAtMs >= it.spec.autoCloseMs) {
stack.items.splice(i, 1);
stack.log.push("timeout:" + it.id + "@" + nowMs);
closed.unshift(it.id);
}
}
return closed;
}
export function topLayer(stack: PopupStack): number {
let m = 0;
for (const it of stack.items) if (it.spec.layer > m) m = it.spec.layer;
return m;
}
// 点击是否被拦截:存在模态弹窗且其层级高于目标层级 → 点击穿不透。
export function clickBlocked(stack: PopupStack, targetLayer: number): boolean {
return stack.items.some((it) => it.mask && it.spec.layer > targetLayer);
}
export function maskVisible(stack: PopupStack): boolean { return stack.items.some((it) => it.mask); }
// 栈摘要:id:layer:mask 序列,稳定可比。
export function dump(stack: PopupStack): string {
return stack.items.map((it) => it.id + ":" + it.spec.layer + ":" + (it.mask ? "M" : "-")).join(",");
}
配套的 core/test/e11_popup.test.ts 有 6 个用例,覆盖压栈、去重、超时、穿透、三种关闭与点遮罩关模态,跑 node --test 'core/test/e11_*.test.ts' 全过。
W 层:能真的点一圈的页面
下面这段是 W 层构建脚本 web/build_e11_popup.mjs。页面把栈画成层级条带,用一排按钮打开不同弹窗,一个遮罩 DOM 覆盖底层界面,还有一个虚拟时钟——点「推进 1600ms」就让 now 往前走,触发超时关闭。全流程可点、可复现。
本次复核修正了状态标签与遮罩重复使用 id="mask" 的问题:状态改用 maskStatus,遮罩保留 mask。网页外壳同时把脚本放进函数作用域,避免 top 等内核名称与 Window 内置属性冲突;Node 单测不能发现这类浏览器环境差异。
#!/usr/bin/env node
// file: web/build_e11_popup.mjs
// tier: W
// verify: node web/build_e11_popup.mjs
// 把 core/src/e11/popup.ts 的弹窗栈打成单文件 HTML:按钮打开不同层级弹窗,点遮罩关闭最上层模态,
// 用虚拟时钟推进超时自动关闭,并演示模态弹窗对底层点击的拦截。产物 web/dist/e11_popup.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_popup.json"), "utf8"));
const js = bundle(ROOT, ["core/src/e11/popup.ts"]);
const script = `const cfg = ${inlineConfig(config)};
/* ---- core bundle: TS -> strip -> inline ---- */
${js}
/* ---- end bundle ---- */
const SPECS = cfg.specs, specOf = (id) => SPECS.find((s) => s.id === id);
let stack = createStack(), now = 0, lastEvent = "-";
const cv = document.getElementById("layers"), ctx = cv.getContext("2d");
const set = (id, v) => { document.getElementById(id).textContent = String(v); };
function render() {
const order = stack.items.slice().sort((a, b) => a.spec.layer - b.spec.layer);
const tl = topLayer(stack), maskZ = tl > 0 ? tl - 1 : 0;
const mask = document.getElementById("mask"); // 遮罩 DOM:模态存在才显示
mask.style.display = maskVisible(stack) ? "block" : "none"; mask.style.zIndex = String(maskZ);
document.getElementById("popups").innerHTML = order.map((it) =>
'<div class="pop' + (it.mask ? " modal" : "") + '" style="z-index:' + it.spec.layer + '">' +
it.id + " · L" + it.spec.layer + (it.mask ? " · 模态" : " · 提示") + "</div>").join("");
ctx.fillStyle = "#0b1220"; ctx.fillRect(0, 0, cv.width, cv.height); // 层级条带
for (const it of order) {
const y = Math.max(0, cv.height - Math.min(cv.height, it.spec.layer));
ctx.fillStyle = it.mask ? "#38bdf8" : "#f59e0b"; ctx.fillRect(10, y, cv.width - 20, 6);
ctx.fillStyle = "#e2e8f0"; ctx.font = "11px system-ui"; ctx.fillText(it.id + " L" + it.spec.layer, 14, Math.max(12, y - 4));
}
if (maskVisible(stack)) { ctx.fillStyle = "rgba(15,23,42,0.55)"; ctx.fillRect(0, 0, cv.width, cv.height); }
set("dump", dump(stack) || "(空)"); set("count", stack.items.length); set("topLayer", tl);
set("maskStatus", maskVisible(stack) ? "显示" : "隐藏");
set("blocked", clickBlocked(stack, cfg.scenario.baseLayer) ? "拦截" : "穿透");
set("event", lastEvent); set("now", now);
globalThis.__pebble = { dump: dump(stack), count: stack.items.length, topLayer: tl,
maskVisible: maskVisible(stack), blocked0: clickBlocked(stack, cfg.scenario.baseLayer), lastEvent, now,
maskShown: mask.style.display === "block", popupIds: order.map((it) => it.id), maskZ, ready: true };
}
const doOpen = (id) => { const r = open(stack, specOf(id), now); lastEvent = r.ok ? "opened:" + id : r.reason; render(); };
document.getElementById("openReward").addEventListener("click", () => doOpen("reward"));
document.getElementById("openSettings").addEventListener("click", () => doOpen("settings"));
document.getElementById("openLoading").addEventListener("click", () => doOpen("loading"));
document.getElementById("openToast").addEventListener("click", () => doOpen("toastA"));
document.getElementById("openToastB").addEventListener("click", () => doOpen("toastB"));
document.getElementById("closeTop").addEventListener("click", () => { const r = closeTop(stack, now); lastEvent = r.ok ? "closed:" + r.id : r.reason; render(); });
document.getElementById("advance").addEventListener("click", () => { now += cfg.scenario.advanceMs; const c = tick(stack, now); lastEvent = c.length ? "timeout:" + c.join(",") : "no-timeout"; render(); });
document.getElementById("baseClick").addEventListener("click", () => { lastEvent = clickBlocked(stack, cfg.scenario.baseLayer) ? "base:blocked" : "base:clicked"; render(); });
document.getElementById("mask").addEventListener("click", () => { const r = closeTopModal(stack, now); lastEvent = r.ok ? "mask-close:" + r.id : r.reason; render(); });
render();
`;
const body = ` <h1>《合成小岛》· 弹窗栈、遮罩与状态反馈</h1>
<div class="sub">内核由 core/src/e11/popup.ts 提供:层级压栈 + 同键去重 + 超时自动关闭 + 模态遮罩拦截点击</div>
<div class="card"><div class="k">栈</div><div class="big" id="dump" style="font-size:14px">(空)</div></div>
<div class="card row"><div><div class="k">层数</div><div class="big" id="count">0</div></div><div><div class="k">最高层</div><div class="big" id="topLayer">0</div></div><div><div class="k">遮罩</div><div class="big" id="maskStatus">隐藏</div></div><div><div class="k">底层点击</div><div class="big" id="blocked">穿透</div></div></div>
<div class="card"><div class="k">最近事件 / 虚拟时钟</div><div class="big" id="event" style="font-size:16px">-</div><div class="k">now = <span id="now">0</span> ms</div></div>
<div class="card" style="position:relative;height:200px;overflow:hidden;background:#0b1220">
<div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#64748b">底层游戏画面</div>
<div id="mask" style="position:absolute;inset:0;background:rgba(15,23,42,0.55);display:none;cursor:pointer"></div>
<div id="popups" style="position:absolute;inset:0;pointer-events:none"></div>
</div>
<div class="card"><canvas id="layers" width="360" height="150" style="width:100%"></canvas></div>
<div class="card row"><button id="openReward">打开奖励</button><button id="openSettings">打开设置</button><button id="openLoading">打开加载(5s)</button><button id="openToast">打开提示A</button><button id="openToastB">打开提示B</button></div>
<div class="card row"><button id="closeTop">关闭栈顶</button><button id="advance">推进 1600ms</button><button id="baseClick">点底层(测穿透)</button></div>`;
const css = `.pop { position: absolute; left: 24px; right: 24px; top: 56px; padding: 12px; border-radius: 10px; font-size: 13px; background: #1e293b; border: 1px solid #334155; color: #e2e8f0; }
.pop.modal { background: #172554; border-color: #3b82f6; }`;
const bytes = writePage(join(ROOT, "web/dist/e11_popup.html"),
page({ title: "《合成小岛》弹窗栈原型 · e11", css, body, script }));
console.log(JSON.stringify({ html: "web/dist/e11_popup.html", htmlBytes: bytes, bundleLines: js.split("\n").length }, null, 2));
W 层:无头验收。 web/verify_e11_popup.mjs 用 Playwright 无头真实点击按钮,把「打开 → 叠加 → 去重 → 点底层 → 点遮罩 → 超时 → 关空」整条链路走一遍并逐步断言。
#!/usr/bin/env node
// file: web/verify_e11_popup.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_e11_popup.mjs web/dist/e11_popup.html
// W 层验收:Playwright 无头真实点击按钮打开/关闭弹窗、点遮罩关闭最上层模态、推进虚拟时钟触发超时;
// 断言栈内容、层级、遮罩可见性与点击拦截状态,检查遮罩 DOM 真的显示、画布真画了像素,并截图。
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_popup.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: 1200 } });
page.on("pageerror", (e) => errors.push(String(e)));
await page.goto(url);
await page.waitForFunction(() => globalThis.__pebble?.ready === true, null, { timeout: 10000 });
const snap = () => page.evaluate(() => globalThis.__pebble);
const maskShown = () => page.evaluate(() => getComputedStyle(document.getElementById("mask")).display !== "none");
const s0 = await snap();
assert.deepEqual([s0.count, s0.maskShown, s0.blocked0], [0, false, false]);
await page.click("#openReward");
const s1 = await snap();
assert.deepEqual([s1.count, s1.dump, s1.topLayer, s1.blocked0], [1, "reward:100:M", 100, true]);
assert.equal(await maskShown(), true);
await page.click("#openSettings");
await page.click("#openReward"); // 同 dedupeKey 应被拒
const s2 = await snap();
assert.deepEqual([s2.count, s2.dump, s2.topLayer, s2.lastEvent], [2, "reward:100:M,settings:120:M", 120, "dupe:reward"]);
await page.click("#openToast");
await page.click("#openToastB"); // 同 dedupeKey "toast" 应被拒
const s3 = await snap();
assert.deepEqual([s3.count, s3.dump, s3.lastEvent], [3, "reward:100:M,settings:120:M,toastA:300:-", "dupe:toast"]);
await page.click("#baseClick");
const s4 = await snap();
assert.deepEqual([s4.lastEvent, s4.count], ["base:blocked", 3]);
await page.click("#mask"); // 点遮罩关闭最上层模态 settings,Toast 不受影响
const s5 = await snap();
assert.deepEqual([s5.dump, s5.topLayer, s5.lastEvent], ["reward:100:M,toastA:300:-", 300, "mask-close:settings"]);
await page.click("#openLoading");
await page.click("#closeTop");
assert.equal((await snap()).count, 2);
await page.click("#advance"); // 推进 1600ms,toastA(1500ms) 超时关闭
const s7 = await snap();
assert.deepEqual([s7.now, s7.lastEvent, s7.dump], [1600, "timeout:toastA", "reward:100:M"]);
await page.click("#mask"); // 关闭 reward,栈空
const s8 = await snap();
assert.deepEqual([s8.count, s8.maskShown], [0, false]);
assert.equal(await maskShown(), false);
await page.click("#baseClick");
assert.equal((await snap()).lastEvent, "base:clicked");
const painted = await page.evaluate(() => {
const cv = document.getElementById("layers");
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_popup.png"), fullPage: true });
await browser.close();
assert.equal(errors.length, 0, `页面错误: ${errors.join("; ")}`);
assert.ok(painted > 10000, `层级画布应有绘制,实测 ${painted} px`);
console.log(JSON.stringify({ page: "web/dist/e11_popup.html", open: s1.dump, stacked: s3.dump,
afterMaskClose: s5.dump, afterTimeout: s7.dump, finalCount: s8.count,
dedupe: [s2.lastEvent, s3.lastEvent], blockedThenPassthrough: [s4.lastEvent, "base:clicked"],
canvasPaintedPx: painted, screenshot: "out/shots/e11_popup.png" }, null, 2));
console.log("SMOKE-W11P: PASS");
实跑走一圈(同机相对量级,绝对数值不可移植),每一步的栈摘要如下:
| 操作 | 结果 |
|---|---|
| 打开奖励 | reward:100:M,最高层 100,底层点击被拦截,遮罩显示 |
| 再开奖励 | 被拒(dupe:reward),栈不变 |
| 打开提示A / 提示B | 提示A 入栈到 300;提示B 被拒(dupe:toast) |
| 点底层 | base:blocked(被模态遮罩挡住) |
| 点遮罩 | 关掉最上层模态 settings,栈变 reward:100:M,toastA:300:- |
| 推进 1600ms | toastA(1500ms)超时自动关,栈只剩 reward:100:M |
| 再点遮罩 | 关掉 reward,栈空、遮罩隐藏,底层点击变 base:clicked |
三个关键读法:叠加到三层时最高层是 Toast(300)而非模态;点遮罩只关了 settings 而没动 Toast,这就是「关最上层模态」而非「关栈顶」的意义;栈空后遮罩 DOM 真的隐藏(getComputedStyle 判为 none),底层点击恢复穿透。层级画布绘制 54000 px,SMOKE-W11P: PASS,截图落在 out/shots/e11_popup.png。
这一节要记住的三件事
- 弹窗是栈,不是散落的框。压栈 / 出栈 / 栈顶 / 层级四个概念一立,谁盖谁、关哪个、会不会堆,全都变成确定的算法。
- 遮罩的职责是路由点击,不只是变暗。判据只有一句——「存在更高层级的模态弹窗则拦截」;遮罩层级取最上层模态减一,既能挡住下面、又不会挡住自己。
- 去重和超时属于栈,不属于调用点。同
dedupeKey拒绝重入、autoCloseMs到点自关,交给栈统一处理,才不会出现「同一个奖励弹了三遍」或「Toast 忘了关」。
必须诚实标注:本节确定的是弹窗栈与穿透判定的算法口径(整数毫秒、层级排序、去重键、可复现),不是任何具体引擎的弹窗组件实现;各引擎的 Modal / Dialog / Toast 组件的层级与遮罩行为各有差异,请对照其官方文档。真实平台对弹窗遮挡、诱导点击的审核口径也需以平台最新规则为准,属于「需真实业务环境,命令行不可复现」。
完整 Node 负例与边界验收
以下文件补齐之前只有命令和输出、没有交付的弹窗用例。浏览器检查仍须另跑,尤其验证 DOM 标识和窗口全局名称差异。
// file: core/test/e11_popup.test.ts
// tier: P
// verify: node --test core/test/e11_popup.test.ts
import {test} from "node:test";
import assert from "node:assert/strict";
import {readFileSync} from "node:fs";
import {createStack,open,closeTop,closeTopModal,tick,maskVisible,clickBlocked,dump} from "../src/e11/popup.ts";
const cfg=JSON.parse(readFileSync(new URL("../../config/e11_popup.json",import.meta.url),"utf8"));
const spec=(id:string)=>cfg.specs.find((s:{id:string})=>s.id===id);
test("same dedupe key refused, first popup preserved",()=>{
const s=createStack();assert.equal(open(s,spec('reward'),0).ok,true);
assert.equal(open(s,spec('reward'),1).ok,false);assert.equal(s.items.length,1);
});
test("modal blocks base, close modal restores empty stack",()=>{
const s=createStack();open(s,spec('reward'),0);
assert.equal(maskVisible(s),true);assert.equal(clickBlocked(s,0),true);
assert.equal(closeTopModal(s,1).ok,true);assert.equal(maskVisible(s),false);
});
test("empty close returns controlled failure",()=>{
const s=createStack();assert.equal(closeTop(s,0).ok,false);assert.equal(closeTopModal(s,0).ok,false);
});
test("timeout closes only expired transient popup",()=>{
const s=createStack();open(s,spec('reward'),0);open(s,spec('toastA'),0);
tick(s,cfg.scenario.advanceMs);
assert.ok(dump(s).includes('reward'));assert.ok(!dump(s).includes('toastA'));
});
阅读导航:上一节:小游戏 UI 系统:布局 / 层级 / 滚动 · 下一节:UI 性能与合批 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。