《Cocos 游戏开发入门》11.3 弹窗栈与界面状态机

暂停面板、设置面板、确认框可能同时出现,谁盖在谁上面、点击落在谁身上、返回键该关哪个?本节给 mini2d 实现弹窗栈:用 push/pop/replace 管理层级,用遮罩与命中测试决定点击归属,用界面状态机约束屏幕跳转,并统一处理返回键。末节对照 Cocos Creator 3.8 的 BlockInputEvents、PageView 与输入系统。

11.3 弹窗栈与界面状态机

《方块冲刺》跑起来之后,界面的复杂度会悄悄翻倍。玩家按下暂停,弹出暂停面板;在暂停面板里点「设置」,设置面板盖上来;在设置里点「退出关卡」,又弹出一个确认框。此刻屏幕上叠着三层,问题接踵而至:点击确认框外面的区域,会不会误触到下面的「继续游戏」?玩家按返回键,应该关掉哪一层?如果此时收到网络断开的消息,又要插入一个提示,它该插在第几层?

这些问题的共同点是:它们都是「层级」和「顺序」的问题,而不是「画什么」的问题。 用一个栈来管理弹窗、用一台状态机来约束屏幕跳转,是业界最通用的答案。

本节新增 src/ui/popup-stack.ts 与 src/ui/screen-machine.ts:前者管弹窗的层级、遮罩与点击路由,后者管屏幕之间的合法跳转,两者再合起来处理返回键。下一章开始,我们把界面之外的「游戏状态」也纳入状态机,做存档与迁移。

11.3.1 用栈管理弹窗层级

栈的规则很简单:后进的弹窗盖在先进的上面,关闭时总是关最上面那个。用数组实现即可,push 入栈、pop 出栈、replace 换掉栈顶(用于「设置」→「关于」这类同级跳转)。

// src/ui/popup-stack.ts
export interface PopupDef {
  /** 弹窗唯一标识,如 'pause'、'settings'、'confirm-quit' */
  id: string;
  /** 是否绘制全屏遮罩 */
  modal: boolean;
  /** 点击遮罩是否关闭本弹窗 */
  dismissOnMask: boolean;
}

export interface PopupInstance extends PopupDef {
  /** 同一 id 可能被 push 多次,用序号区分实例 */
  seq: number;
}

export class PopupStack {
  private items: PopupInstance[] = [];
  private nextSeq = 1;

  get depth(): number {
    return this.items.length;
  }

  /** 只读快照,供命中测试等外部逻辑遍历 */
  snapshot(): readonly PopupInstance[] {
    return this.items;
  }

  top(): PopupInstance | null {
    return this.items.length > 0 ? this.items[this.items.length - 1] : null;
  }

  push(def: PopupDef): PopupInstance {
    const inst: PopupInstance = { ...def, seq: this.nextSeq };
    this.nextSeq += 1;
    this.items.push(inst);
    return inst;
  }

  pop(): PopupInstance | null {
    return this.items.pop() ?? null;
  }

  /** 用新弹窗替换栈顶:栈深不变,适合同级页面切换 */
  replace(def: PopupDef): PopupInstance {
    this.items.pop();
    return this.push(def);
  }

  /** 关掉栈顶以下的所有弹窗,只留最底一层 */
  popToRoot(): void {
    if (this.items.length > 1) this.items.length = 1;
  }

  has(id: string): boolean {
    return this.items.some((it) => it.id === id);
  }
}

seq 是必要的:如果只用 id 做键,同一个「确认框」被连开两次,关闭时无法区分关的是哪一个。replace 与 pop + push 的区别也值得强调——replace 的栈深不变,因此不会影响「返回键应该回到哪里」的判断。

11.3.2 遮罩与点击穿透

有了层级,下一步是决定一次点击落到谁身上。规则是从栈顶往下找:先看最上层是否命中;没命中且该层是模态(有遮罩),说明点击被遮罩吃掉,直接返回「被阻断」;否则继续往下找。

export interface HitResult {
  target: PopupInstance | null;
  blocked: boolean;
}

/**
 * 路由一次点击。hitTest 判断某层是否命中坐标。
 * 从栈顶向下遍历,遇到 modal 层且未命中即阻断。
 */
export function routePointer(
  stack: PopupStack,
  hitTest: (inst: PopupInstance, x: number, y: number) => boolean,
  x: number,
  y: number,
): HitResult {
  const items = stack.snapshot();
  for (let i = items.length - 1; i >= 0; i -= 1) {
    const inst = items[i];
    if (hitTest(inst, x, y)) return { target: inst, blocked: false };
    if (inst.modal) return { target: null, blocked: true };
  }
  return { target: null, blocked: false };
}

这里把「遮罩」理解成一个布尔属性而非一张图片。绘制上它可能是一层半透明黑,但逻辑上它只是一句「本层之下的点击一律不响应」。把视觉与逻辑拆开,是因为很多弹窗并不需要变暗背景,却仍然要阻断点击(比如全屏加载遮罩)——modal 管逻辑,透明度管视觉,两者独立配置。

blocked 的返回值很重要:如果 routePointer 返回 blocked: true,上层的输入分发就应该把这次触摸消费掉,不再传给游戏世界。否则会出现「暂停时还能控制角色跳跃」这类经典 bug。

11.3.3 界面状态机约束跳转

弹窗是「叠加」的,屏幕是「互斥」的。主菜单、游戏中、暂停、结算,同一时刻只有一个。用一台状态机描述它们之间的合法跳转,比散落各处的 if (state === ...) 可靠得多。

// src/ui/screen-machine.ts
export type ScreenId = 'boot' | 'menu' | 'playing' | 'paused' | 'result';

/** 允许的跳转表:从 key 只能到 value 中列出的屏幕 */
const TRANSITIONS: Record<ScreenId, readonly ScreenId[]> = {
  boot: ['menu'],
  menu: ['playing'],
  playing: ['paused', 'result'],
  paused: ['playing', 'menu'],
  result: ['menu', 'playing'],
};

export class ScreenMachine {
  private current: ScreenId = 'boot';

  get state(): ScreenId {
    return this.current;
  }

  canGo(to: ScreenId): boolean {
    return TRANSITIONS[this.current].includes(to);
  }

  /** 合法则跳转并返回 true,否则保持原状返回 false */
  go(to: ScreenId): boolean {
    if (!this.canGo(to)) return false;
    this.current = to;
    return true;
  }
}

跳转表是显式的数据,因此「boot 不能直接跳到 playing」这条规则可以被单测直接验证,而不是靠读代码推断。当产品后来要求「结算页可以直接再来一局」,只需要在 result 那一行加上 playing,无需改动任何调用点。

11.3.4 返回键的统一处理

安卓的返回键、小程序的左上角返回、H5 的 Esc,语义是同一个:退回上一层。它的处理优先级是固定的——先关最上面的弹窗,弹窗关完了再回退屏幕。

/**
 * 统一处理返回键。返回 true 表示事件已被消费,不再向上传递。
 * 优先级:栈顶弹窗 > 屏幕回退。
 */
export function handleBack(
  stack: PopupStack,
  machine: ScreenMachine,
): boolean {
  if (stack.top() !== null) {
    stack.pop();
    return true;
  }
  if (machine.state === 'paused') return machine.go('menu');
  if (machine.state === 'playing') return machine.go('paused');
  return false;
}

如果 handleBack 返回 false,说明已经到了最外层(主菜单),此时才允许平台去执行默认行为,比如退出小游戏。这个「谁消费事件」的约定,是移动端 UI 里最容易出错、也最值得写单测的地方。

11.3.5 单元测试

// tests/popup-stack.test.ts
import { describe, it, expect } from 'vitest';
import { PopupStack, routePointer } from '../src/ui/popup-stack';
import { ScreenMachine, handleBack } from '../src/ui/screen-machine';

const modalPopup = { id: 'pause', modal: true, dismissOnMask: false };

describe('popup-stack', () => {
  it('push/pop 维持后进先出', () => {
    const s = new PopupStack();
    s.push(modalPopup);
    s.push({ id: 'settings', modal: true, dismissOnMask: false });
    expect(s.depth).toBe(2);
    expect(s.top()?.id).toBe('settings');
    expect(s.pop()?.id).toBe('settings');
    expect(s.top()?.id).toBe('pause');
  });

  it('模态弹窗阻断下层点击', () => {
    const s = new PopupStack();
    s.push(modalPopup);
    const r = routePointer(s, () => false, 10, 10);
    expect(r.target).toBeNull();
    expect(r.blocked).toBe(true);
  });

  it('非模态弹窗不阻断下层点击', () => {
    const s = new PopupStack();
    s.push({ id: 'toast', modal: false, dismissOnMask: false });
    const r = routePointer(s, () => false, 10, 10);
    expect(r.blocked).toBe(false);
  });

  it('状态机拒绝非法跳转', () => {
    const m = new ScreenMachine();
    expect(m.go('playing')).toBe(false); // boot → playing 非法
    expect(m.go('menu')).toBe(true);
    expect(m.go('playing')).toBe(true);
    expect(m.state).toBe('playing');
  });

  it('返回键先关弹窗再回退屏幕', () => {
    const s = new PopupStack();
    const m = new ScreenMachine();
    m.go('menu');
    m.go('playing');
    s.push(modalPopup);
    expect(handleBack(s, m)).toBe(true);
    expect(s.depth).toBe(0);
    expect(m.state).toBe('playing');
    expect(handleBack(s, m)).toBe(true);
    expect(m.state).toBe('paused');
  });
});

第三个用例是防回归的关键:很多人为了省事,把所有弹窗都设成 modal,结果 toast 提示也会阻断操作。单测把「非模态不阻断」固化下来,以后重构就不容易退化。

11.3.6 Cocos 对照:BlockInputEvents、PageView 与输入系统

Cocos Creator 3.8 里,「阻断点击」这件事有一个专用组件:BlockInputEvents。把它挂到弹窗节点上,该节点就会吞掉落在它矩形范围内的所有触摸与鼠标事件,天然实现 routePointer 里 modal 层的效果。

import { _decorator, Component, Node, BlockInputEvents, input, Input, KeyCode, EventKeyboard } from 'cc';
const { ccclass, property } = _decorator;

@ccclass('PopupHost')
export class PopupHost extends Component {
  @property(Node)
  pausePanel: Node | null = null;

  onEnable(): void {
    input.on(Input.EventType.KEY_DOWN, this.onKeyDown, this);
  }

  onDisable(): void {
    input.off(Input.EventType.KEY_DOWN, this.onKeyDown, this);
  }

  private onKeyDown(event: EventKeyboard): void {
    if (event.keyCode !== KeyCode.ESCAPE && event.keyCode !== KeyCode.MOBILE_BACK) {
      return;
    }
    this.closeTopPopup();
  }

  openPause(): void {
    const panel = this.pausePanel;
    if (panel === null) return;
    panel.active = true;
    // 挂上后,落在面板矩形内的触摸不再传给下层
    panel.addComponent(BlockInputEvents);
  }

  closeTopPopup(): void {
    const panel = this.pausePanel;
    if (panel === null) return;
    panel.active = false;
  }
}

对照要点:

  • BlockInputEvents 只按节点矩形拦截,因此它覆盖的是「整块遮罩」的语义;若弹窗形状不规则,仍需自己按 UITransform.getBoundingBox 做命中测试。
  • 返回键用 input.on(Input.EventType.KEY_DOWN, ...) 监听,KeyCode.ESCAPE 对应桌面 Esc,KeyCode.MOBILE_BACK 对应安卓返回键。注意 on 与 off 必须成对出现在 onEnable / onDisable 里,否则节点复用时会重复注册。
  • 分屏切换用 PageView(继承自 ScrollView),它提供 addPage、setCurrentPageIndex、getCurrentPageIndex,等价于状态机里一组互斥的屏幕。
  • 弹窗层级在 Cocos 里靠节点顺序表达:node.setSiblingIndex 越大越靠上。用栈维护顺序,再同步到 siblingIndex,就把 mini2d 的抽象落到了引擎的节点树上。
能力mini2dCocos Creator 3.8
弹窗层级PopupStack 数组顺序节点 siblingIndex
阻断点击routePointer 的 modal 分支BlockInputEvents 组件
命中测试hitTest 回调UITransform.getBoundingBox
屏幕状态机ScreenMachine + 跳转表自定义脚本 + PageView
返回键handleBackinput.on(Input.EventType.KEY_DOWN) + KeyCode.MOBILE_BACK
分屏自定义PageView(addPage / setCurrentPageIndex)

界面这一章到此结束。回过头看,11.1 解决「放在哪」,11.2 解决「渲染多少」,11.3 解决「谁在上、谁响应」——三节合起来,就是一个最小可用但结构完整的 UI 层。下一章我们跨出界面,处理更本质的东西:游戏状态怎么组织、存档怎么序列化、版本迁移怎么做。

阅读导航:上一节:11.2 列表虚拟化与滚动性能 · 下一节:12.1 游戏状态机 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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