11.2 弹窗、提示与状态反馈

把弹窗从「随手 new 一个框」升级成可算的弹窗栈:按层级压栈、同去重键拒绝重复、超时自动关闭、模态遮罩拦截点击穿透、点遮罩只关最上层模态——配可跑内核、单文件 HTML 原型与 Playwright 无头验收,全程整数毫秒、可确定性回放,无需真实时钟。

站内的 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:-
推进 1600mstoastA(1500ms)超时自动关,栈只剩 reward:100:M
再点遮罩关掉 reward,栈空、遮罩隐藏,底层点击变 base:clicked

三个关键读法:叠加到三层时最高层是 Toast(300)而非模态;点遮罩只关了 settings 而没动 Toast,这就是「关最上层模态」而非「关栈顶」的意义;栈空后遮罩 DOM 真的隐藏(getComputedStyle 判为 none),底层点击恢复穿透。层级画布绘制 54000 px,SMOKE-W11P: PASS,截图落在 out/shots/e11_popup.png。

这一节要记住的三件事

  1. 弹窗是栈,不是散落的框。压栈 / 出栈 / 栈顶 / 层级四个概念一立,谁盖谁、关哪个、会不会堆,全都变成确定的算法。
  2. 遮罩的职责是路由点击,不只是变暗。判据只有一句——「存在更高层级的模态弹窗则拦截」;遮罩层级取最上层模态减一,既能挡住下面、又不会挡住自己。
  3. 去重和超时属于栈,不属于调用点。同 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 性能与合批 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制