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的抽象落到了引擎的节点树上。
| 能力 | mini2d | Cocos Creator 3.8 |
|---|---|---|
| 弹窗层级 | PopupStack 数组顺序 | 节点 siblingIndex |
| 阻断点击 | routePointer 的 modal 分支 | BlockInputEvents 组件 |
| 命中测试 | hitTest 回调 | UITransform.getBoundingBox |
| 屏幕状态机 | ScreenMachine + 跳转表 | 自定义脚本 + PageView |
| 返回键 | handleBack | input.on(Input.EventType.KEY_DOWN) + KeyCode.MOBILE_BACK |
| 分屏 | 自定义 | PageView(addPage / setCurrentPageIndex) |
界面这一章到此结束。回过头看,11.1 解决「放在哪」,11.2 解决「渲染多少」,11.3 解决「谁在上、谁响应」——三节合起来,就是一个最小可用但结构完整的 UI 层。下一章我们跨出界面,处理更本质的东西:游戏状态怎么组织、存档怎么序列化、版本迁移怎么做。
阅读导航:上一节:11.2 列表虚拟化与滚动性能 · 下一节:12.1 游戏状态机 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。