《Cocos 游戏开发实战》6.3 弹窗栈与多语言

本节用弹窗栈管理暂停、结算、设置三个面板的压栈出栈与遮罩点击拦截,给出带进出动画的 PopupBase 基类与可直接单测的栈逻辑,并按 sys.languageCode 切换中英文文案表,说明中英文字长差异导致的换行、溢出与字号不一致该如何取舍。

6.3 弹窗栈与多语言

6.2 列表与滚动容器 填满了主界面,但 PopupLayer 还是空的。《方块冲刺》里的弹窗不止一个:暂停面板、通关结算、失败重试、设置、每日奖励。它们会叠加——比如在暂停面板上再弹一个「确认退出」,或者在结算面板上弹一个「领取奖励」。如果每个弹窗各管各的显示隐藏,很快就会出现「两个弹窗都响应点击」「关掉上层下层也没了」「返回键顺序错乱」这类问题。

解法是把弹窗组织成栈:任何时候只有栈顶那个弹窗接收输入,关闭时按后进先出弹回。同时,界面文案要能一键在中英文之间切换,而中英文的字长差异会让同一句提示在两套语言下换行位置完全不同——这也是这一节要处理的。

与站内 game/client/ 那批文章的区分:那些文章把弹窗与本地化当作 UI 设计话题,本节给的是 Cocos Creator 3.8 里 PopupLayer + BlockInputEvents + Label 的具体实现与属性取值。

6.3.1 弹窗栈:为什么不是一堆布尔开关

先用反面教材说明必要性。如果三个弹窗各有一个 isOpen 标志,业务代码里就会出现这样的判断:

// 反面教材:状态散落在各处,很快失控
if (!settingsPanel.isOpen && !pausePanel.isOpen && !resultPanel.isOpen) {
  openDailyReward();
}

问题有三个。第一,「现在能不能打开新弹窗」这个判断要枚举所有弹窗,加一个弹窗就要改所有调用点。第二,点击拦截没有统一入口,暂停面板下面的按钮照样能点到。第三,关闭顺序没有约束,玩家按返回键时不知道该关哪个。

栈把这三件事收进一个结构:push 时自动给下面所有弹窗加输入拦截,pop 时自动解除;「能不能打开」变成看栈深;「返回键关哪个」变成看栈顶。

关注点一堆布尔开关弹窗栈
谁能打开枚举所有弹窗状态看栈深,业务自定义上限
点击拦截每个弹窗自己处理非栈顶统一拦截
关闭顺序无约束后进先出
加新弹窗改所有调用点只加一个注册项

6.3.2 PopupBase:带进出动画的基类

所有弹窗继承同一个基类,把「淡入 + 缩放 + 拦截点击」固化下来。

// src/ui/popup-base.ts —— 所有弹窗的基类
import {
  _decorator, Component, UIOpacity, BlockInputEvents, tween, Vec3,
} from 'cc';
const { ccclass } = _decorator;

@ccclass('PopupBase')
export class PopupBase extends Component {
  protected blocker: BlockInputEvents | null = null;
  protected opacity: UIOpacity | null = null;

  onLoad(): void {
    this.opacity = this.node.getComponent(UIOpacity) ?? this.node.addComponent(UIOpacity);
    // 弹窗出现即拦截输入,避免点穿到下层界面
    this.blocker = this.node.getComponent(BlockInputEvents) ?? this.node.addComponent(BlockInputEvents);
    this.node.setScale(0.85, 0.85, 1);
    this.opacity.opacity = 0;
  }

  /** 打开动画:0.18 秒淡入并放大到原尺寸 */
  open(done?: () => void): void {
    const op = this.opacity;
    if (op === null) return;
    tween(op).to(0.18, { opacity: 255 }).start();
    tween(this.node)
      .to(0.18, { scale: new Vec3(1, 1, 1) }, { easing: 'backOut' })
      .call(() => done?.())
      .start();
  }

  /** 关闭动画:淡出并缩小,结束后从父节点摘除 */
  close(done?: () => void): void {
    const op = this.opacity;
    if (op === null) return;
    tween(op).to(0.14, { opacity: 0 }).start();
    tween(this.node)
      .to(0.14, { scale: new Vec3(0.85, 0.85, 1) })
      .call(() => {
        done?.();
        this.node.removeFromParent();
      })
      .start();
  }
}

BlockInputEvents 是这里的关键:它挂在节点上后,会吞掉落到该节点矩形内的所有触摸,让事件不再向下传递。它必须在弹窗出现时就在,否则打开动画那 0.18 秒里玩家能点到下层的按钮。同理,关闭动画播完之后要把节点从父节点摘掉(removeFromParent)而不是只设 active = false,这样栈管理器可以用「节点还在不在父节点下」判断弹窗是否真的关闭。

6.3.3 PopupStack:栈逻辑与引擎解耦

栈的核心逻辑(压入、弹出、查栈顶)是纯数据操作,抽出来可以直接单测;与引擎相关的只是「按名字找到 Prefab 并实例化」。

// src/core/popup-stack.ts —— 与引擎无关,可直接单测
export class PopupStack {
  private readonly stack: string[] = [];
  private readonly maxDepth: number;

  constructor(maxDepth = 4) {
    this.maxDepth = maxDepth;
  }

  /** 压入一个弹窗名;超过上限或重复压入返回 false */
  push(name: string): boolean {
    if (this.stack.length >= this.maxDepth) return false;
    if (this.stack.includes(name)) return false;
    this.stack.push(name);
    return true;
  }

  /** 弹出栈顶;可指定必须匹配的名字,防止动画错位时误关 */
  pop(name?: string): string | null {
    const top = this.stack[this.stack.length - 1];
    if (top === undefined) return null;
    if (name !== undefined && name !== top) return null;
    this.stack.pop();
    return top;
  }

  get top(): string | null {
    return this.stack[this.stack.length - 1] ?? null;
  }

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

  has(name: string): boolean {
    return this.stack.includes(name);
  }
}

配套单测把两条约束钉死——「重复打开同一个弹窗被拒」和「只能弹出栈顶」:

// tests/popup-stack.test.ts
import { describe, it, expect } from 'vitest';
import { PopupStack } from '../src/core/popup-stack';

describe('PopupStack', () => {
  it('重复压入同名弹窗被拒绝', () => {
    const s = new PopupStack();
    expect(s.push('pause')).toBe(true);
    expect(s.push('pause')).toBe(false);
    expect(s.depth).toBe(1);
  });

  it('指定名字不匹配时拒绝弹出', () => {
    const s = new PopupStack();
    s.push('pause');
    s.push('confirm');
    expect(s.pop('pause')).toBeNull();
    expect(s.top).toBe('confirm');
    expect(s.pop('confirm')).toBe('confirm');
    expect(s.top).toBe('pause');
  });
});

引擎侧的桥接组件只做「查 Prefab → 实例化 → 调 open」:

// src/ui/popup-manager.ts —— 挂在 PopupLayer 上
import { _decorator, Component, Node, Prefab, instantiate } from 'cc';
import { PopupStack } from '../core/popup-stack';
const { ccclass, property } = _decorator;

/** 只声明管理器真正用到的两个方法,避免和弹窗基类强耦合 */
interface PopupLike {
  open(done?: () => void): void;
  close(done?: () => void): void;
}

@ccclass('PopupManager')
export class PopupManager extends Component {
  @property(Prefab) pausePrefab: Prefab | null = null;
  @property(Prefab) resultPrefab: Prefab | null = null;

  private stack = new PopupStack(4);

  private registry: Record<string, Prefab | null> = {};

  onLoad(): void {
    this.registry = { pause: this.pausePrefab, result: this.resultPrefab };
  }

  show(name: string): boolean {
    if (!this.stack.push(name)) return false;
    const prefab = this.registry[name] ?? null;
    if (prefab === null) return false;
    const node: Node = instantiate(prefab);
    this.node.addChild(node);
    const popup = node.getComponent('PopupBase') as unknown as PopupLike | null;
    popup?.open();
    return true;
  }

  dismiss(): void {
    const name: string | null = this.stack.top;
    if (name === null) return;
    const top = this.node.getChildByName(name);
    const popup = top?.getComponent('PopupBase') as unknown as PopupLike | null;
    popup?.close(() => { this.stack.pop(name); });
  }
}

registry 用 Record<string, Prefab | null> 而不是 Map,是因为 @property 装饰的字段在编辑器里要能被序列化,普通对象最省事。注意 close 的回调里才 pop:栈的弹出必须等关闭动画播完,否则「动画还在播但栈已空」,紧接着的 show 会立刻把新弹窗压进来,视觉上两个弹窗会重叠。

6.3.4 多语言:文案表与语言探测

文案表用一张纯数据的映射,key 是稳定的英文标识,值按语言分表。语言由 sys.languageCode 探测(返回形如 zh-cn、en-us 的小写串)。

// src/core/i18n.ts —— 与引擎无关,可直接单测
export type Lang = 'zh' | 'en';

const TABLES: Record<Lang, Record<string, string>> = {
  zh: {
    'hud.coins': '金币',
    'popup.pause': '暂停',
    'popup.resume': '继续',
    'popup.result.win': '通关!',
    'popup.result.lose': '再来一次',
  },
  en: {
    'hud.coins': 'Coins',
    'popup.pause': 'Paused',
    'popup.resume': 'Resume',
    'popup.result.win': 'Level Clear!',
    'popup.result.lose': 'Try Again',
  },
};

/** 从平台上报的 languageCode 归一到受支持的语言 */
export function normalizeLang(code: string): Lang {
  return code.toLowerCase().startsWith('zh') ? 'zh' : 'en';
}

export class I18n {
  private lang: Lang;
  private readonly fallback: Lang;

  constructor(lang: Lang, fallback: Lang = 'zh') {
    this.lang = lang;
    this.fallback = fallback;
  }

  setLang(lang: Lang): void {
    this.lang = lang;
  }

  /** 取文案;当前语言缺 key 时退回 fallback,再缺则原样返回 key */
  t(key: string): string {
    const cur = TABLES[this.lang][key];
    if (cur !== undefined) return cur;
    return TABLES[this.fallback][key] ?? key;
  }
}

引擎侧在启动时按 sys.languageCode 建实例,并把语言写进存档(下次启动直接读,见 11.1 本地存档与版本迁移 ):

import { _decorator, Component, Label, sys } from 'cc';
import { I18n, normalizeLang, Lang } from '../core/i18n';
const { ccclass, property } = _decorator;

@ccclass('I18nBinder')
export class I18nBinder extends Component {
  @property(Label) coinsLabel: Label | null = null;

  private i18n: I18n = new I18n('zh');

  onLoad(): void {
    const lang: Lang = normalizeLang(sys.languageCode);
    this.i18n = new I18n(lang);
    this.refresh();
  }

  refresh(): void {
    if (this.coinsLabel !== null) {
      this.coinsLabel.string = this.i18n.t('hud.coins');
    }
  }

  switchTo(lang: Lang): void {
    this.i18n.setLang(lang);
    this.refresh();
  }
}

t() 的三级回退(当前语言 → 兜底语言 → key 原样)是刻意的:漏翻的 key 会以 key 字面量显示出来,测试时一眼就能发现,比显示空白或上一句残留文案好得多。

6.3.5 文本适配:中英文长度差

同一句「继续」,中文两个字,英文 Resume 六个字母;「通关!」三个字,Level Clear! 十一个字符。同一个 Label 在两种语言下宽度可能差一倍,按钮会被撑破或文字被截断。Label 的 Overflow 决定怎么处理:

Overflow行为适用
NONE超出部分不渲染,直接溢出节点框标题等短文本
CLAMP截断超出部分固定宽度的单行标签
SHRINK自动缩小字号塞进框内按钮文字(本工程采用)
RESIZE_HEIGHT自动增高节点以容纳换行多行说明文字

编辑器里给按钮上的 Label 设(需 Cocos Creator 3.8 编辑器):Overflow 选 SHRINK,Enable Wrap Text 关闭,Font Size 填 30。操作后属性面板应显示:Label 的 Overflow = SHRINK、Enable Wrap Text = false、Font Size = 30。用 SHRINK 的代价是——中英文的最终字号可能不同,若设计要求两种语言字号一致,就得把按钮宽度按最长的英文留够,改用 CLAMP。

需要动态换行的多行文案(比如结算面板的提示),把 Overflow 设成 RESIZE_HEIGHT、同时打开 Enable Wrap Text,节点高度会随换行自动增长;lineHeight 取 34,与 6.1 里 HUD 的字号保持同一套视觉节奏。

6.3.6 工程收口

这一节做完,第 6 章收尾,工程里多了这些确定的东西:

  • assets/scripts/ui/popup-base.ts:弹窗基类,进出动画 + 输入拦截。
  • assets/scripts/ui/popup-manager.ts:挂在 PopupLayer 上,Prefab 注册表 + 栈桥接。
  • assets/scripts/core/popup-stack.ts:栈逻辑,纯 TS 可单测。
  • assets/scripts/core/i18n.ts:中英文案表与回退,纯 TS 可单测。
  • assets/scripts/ui/i18n-binder.ts:按 sys.languageCode 绑定语言并刷新 Label。
  • assets/prefabs/ 下新增 PausePopup.prefab、ResultPopup.prefab。

回头看第 5、6 两章的收束:启动流程把玩家从 boot 送进 main(5.1),转场与预加载保证切换不黑屏、不卡顿(5.2),常驻根让音频与存档活过整局(5.3),UI 骨架给出统一的坐标系(6.1),列表与弹窗填满界面(6.2、6.3)。到这里,游戏「能看、能点、能切」,但还不能玩——下一章开始做真正的玩法:输入如何映射到角色跳跃、碰撞如何判定、以及怎么把跑酷的「手感」拆成可调的参数。

阅读导航:上一节:6.2 列表与滚动容器 · 下一节:7.1 输入与角色控制 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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