《Cocos 游戏开发实战》5.1 启动流程与首屏

本节把《方块冲刺》的启动链路拆成引擎初始化、boot 场景加载、首包资源预取三个阶段,给出 BootFlow 组件的完整实现、加权进度条算法与微信小游戏的启动约束,并说明 boot.scene 在编辑器里打开时会看到哪些节点、挂哪些组件、属性面板显示什么值。

5.1 启动流程与首屏

到第 4 章为止,block-dash 的 assets/ 已经按 scenes/ prefabs/ textures/ audio/ configs/ scripts/ 分了层,资源也进了 bundle-levels 分包,但工程还没有一个「能被玩家看见的第一屏」。打开工程直接运行,Cocos 会加载启动场景——如果那个场景就是主玩法,玩家会先对着黑屏等两三秒,然后画面突然出现,中间没有任何反馈。

启动流程要解决的就是这件事:把「加载」这段不可避免的等待,变成一个可以设计、可以度量、可以逐平台微调的阶段。 本节的产出是一个只有几十个节点的 boot.scene 和一个 BootFlow 组件,它负责读配置、探存档、拉首包资源、把进度画到屏幕上,最后才把控制权交给主场景。

与站内 game/client/ 那批工程文的分工:那些文章讲的是「某引擎的工程结构长什么样」,本节讲的是 Cocos Creator 3.8 的启动场景在编辑器里怎么搭、启动脚本怎么写、boot.scene 序列化成什么样。

5.1.1 从点击图标到主界面:三段链路

一个 H5 / 小游戏 / 原生包从被点到进主界面,中间隔着三段互不相同的等待,混在一起看会误判优化点。

阶段耗时来源谁在管能否显示进度
引擎初始化JS 引擎启动、渲染后端协商、首包代码解析Cocos 运行时(game)不能,此时没有可绘制的帧
boot 场景加载下载并反序列化 boot.scene 及其依赖director不能,进度条自己还没被加载出来
首包资源预取configs/ 数值表、首屏图集、基础音效我们自己的 BootFlow能,这是唯一值得画进度的一段

结论很直接:进度条只能覆盖第三段,前两段能做的只有「让它更短」。所以工程里要做两件事——把 boot 场景做到极小(缩短第二段),以及把能在第三段并行做的预取全部塞进去(掩盖第一、二段的固定开销)。这也解释了为什么 boot.scene 里不能放玩法资源:一旦放了,第二段就会变长,而这段时间屏幕是黑的。

5.1.2 引擎事件:能拿到的最早几个钩子

在场景被创建之前,代码已经可以通过 game 拿到若干生命周期事件。3.8 里与启动相关的是这几个(都查过 cc.d.ts 的 Game 类静态成员):

  • Game.EVENT_ENGINE_INITED:引擎核心初始化完成,此时 cc 模块可用。
  • Game.EVENT_POST_BASE_INIT / Game.EVENT_POST_SUBSYSTEM_INIT / Game.EVENT_POST_PROJECT_INIT:基础库、子系统、工程数据依次就绪。
  • Game.EVENT_GAME_INITED:游戏整体初始化完成。

这些事件适合做「全局前置」的事情,比如初始化埋点 SDK、注册全局错误上报、根据 sys.platform 决定要不要提前申请小游戏的更新检查。真正要加载场景时,用的是 director:

import { _decorator, Component, director, game, Game, sys, view, ResolutionPolicy } from 'cc';
const { ccclass } = _decorator;

@ccclass('AppEntry')
export class AppEntry extends Component {
  onLoad(): void {
    // 设计分辨率在工程里锁死为 960×540,这里显式再设一次,
    // 保证在编辑器预览与真机上取到的是同一套可见尺寸
    view.setDesignResolutionSize(960, 540, ResolutionPolicy.SHOW_ALL);

    // 小游戏切后台会冻结定时器,回前台必须重新对时,
    // 这里先注册好,避免玩法层再各自注册一遍
    game.on(Game.EVENT_SHOW, this.onShow, this);
    game.on(Game.EVENT_HIDE, this.onHide, this);

    // 记录冷启动时刻,供 15.3 的启动耗时统计使用
    this.bootAt = game.totalTime;
  }

  private bootAt = 0;

  private onShow(): void {
    // 回前台:交回给常驻的游戏状态管理器(见 5.3)
  }

  private onHide(): void {
    // 切后台:暂停音频、落盘存档(见 5.3)
  }
}

AppEntry 挂在 boot 场景的根节点上,是整个游戏第一个被执行的组件。它的职责边界要划清楚:只做「与场景无关的全局初始化」,不碰任何玩法资源。

5.1.3 boot 场景:首屏必须轻

boot 场景的目标是「在一帧内就能画出一张能让玩家接受的静态图」。它只需要四样东西:一个 UI 渲染根、一张全屏底色或 logo、一个进度条、一行版本文字。

下面是 boot.scene 的节点结构(L3 工程文件,需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现):

[
  { "__type__": "cc.SceneAsset", "_name": "boot", "scene": { "__id__": 1 } },
  {
    "__type__": "cc.Scene",
    "_name": "boot",
    "_children": [ { "__id__": 2 } ],
    "_globals": { "__id__": 9 }
  },
  {
    "__type__": "cc.Node",
    "_name": "Canvas",
    "_parent": { "__id__": 1 },
    "_components": [ { "__id__": 3 }, { "__id__": 4 }, { "__id__": 5 } ],
    "_children": [ { "__id__": 6 }, { "__id__": 7 } ]
  },
  { "__type__": "cc.UITransform", "node": { "__id__": 2 }, "_contentSize": { "width": 960, "height": 540 } },
  { "__type__": "cc.Canvas", "node": { "__id__": 2 }, "_cameraComponent": { "__id__": 8 } },
  { "__type__": "cc.Widget", "node": { "__id__": 2 } },
  { "__type__": "cc.Node", "_name": "ProgressBar", "_parent": { "__id__": 2 } },
  { "__type__": "cc.Node", "_name": "VersionLabel", "_parent": { "__id__": 2 } },
  { "__type__": "cc.Camera", "node": { "__id__": 8 }, "_projection": 0 }
]

需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。 在编辑器里双击 assets/scenes/boot.scene,层级管理器会看到 Canvas 下只有 ProgressBar 与 VersionLabel 两个子节点;属性面板里 Canvas 挂着 UITransform(Content Size 自动等于设计分辨率 960×540)、Canvas、Widget 三个组件,ProgressBar 节点挂着 Sprite + ProgressBar,VersionLabel 挂着 Label。Canvas 上还会自动带一个子节点 Camera(UI 相机),它的 Projection 是正交、Clear Flags 是 SOLID_COLOR。整个场景序列化后不到 20 KB,这是刻意的。

5.1.4 BootFlow:把等待变成可度量的阶段

BootFlow 是本节的主角。它按顺序推进若干「步骤」,每一步自己上报权重与完成度,最后统一画到进度条上。这样即使某一步(比如拉数值表)比其他步慢十倍,进度条也不会卡在 90% 不动。

import {
  _decorator, Component, director, assetManager, resources, JsonAsset,
  ProgressBar, Label, sys,
} from 'cc';
const { ccclass, property } = _decorator;

interface BootStep {
  readonly name: string;
  readonly weight: number;
  run(done: (ok: boolean) => void): void;
}

@ccclass('BootFlow')
export class BootFlow extends Component {
  @property(ProgressBar) bar: ProgressBar | null = null;
  @property(Label) tip: Label | null = null;

  private steps: BootStep[] = [];
  private totalWeight = 0;
  private finishedWeight = 0;

  onLoad(): void {
    this.steps = [
      { name: 'configs', weight: 2, run: (d) => this.loadConfigs(d) },
      { name: 'bundle', weight: 5, run: (d) => this.loadLevelBundle(d) },
      { name: 'home', weight: 3, run: (d) => this.preloadHome(d) },
    ];
    this.totalWeight = this.steps.reduce((s, x) => s + x.weight, 0);
    this.runNext(0);
  }

  private runNext(i: number): void {
    if (i >= this.steps.length) {
      this.finish();
      return;
    }
    const step = this.steps[i];
    this.setTip(step.name);
    step.run((ok) => {
      if (!ok) {
        this.setTip('加载失败,请检查网络后重试');
        return;
      }
      this.finishedWeight += step.weight;
      this.paint(this.finishedWeight / this.totalWeight);
      this.runNext(i + 1);
    });
  }

  private loadConfigs(done: (ok: boolean) => void): void {
    resources.load('configs/levels', JsonAsset, (err, asset) => {
      if (err !== null || asset === null) {
        done(false);
        return;
      }
      done(true);
    });
  }

  private loadLevelBundle(done: (ok: boolean) => void): void {
    assetManager.loadBundle('bundle-levels', (err, bundle) => {
      done(err === null && bundle !== null);
    });
  }

  private preloadHome(done: (ok: boolean) => void): void {
    resources.preloadDir('textures/home', () => done(true));
  }

  private paint(ratio: number): void {
    if (this.bar !== null) this.bar.progress = ratio;
  }

  private setTip(text: string): void {
    if (this.tip !== null) this.tip.string = text;
  }

  private finish(): void {
    // 到这一步首包资源已就位,切主场景不会再有可感知的等待
    director.loadScene('main', (err) => {
      if (err !== null) {
        console.error('[BootFlow] 进入主场景失败', err);
      }
    });
  }
}

注意 director.loadScene('main') 是异步的:它会先加载 main 场景及其依赖,加载完成后才销毁 boot 场景。所以进度条走满之后、画面切走之前,仍有一小段由 main 场景加载决定的停顿。3.8 提供了 director.preloadScene 把这段提前,见 5.2 场景切换与预加载 。

5.1.5 加权进度:纯逻辑,可以单测

BootFlow 里那段「按权重累加」的算法与引擎无关,抽出来就是一段纯 TS,可以直接用 vitest 覆盖。它的价值在于:进度条在失败或超时的情况下也必须单调不减,否则玩家会看到进度回退。

// src/core/boot-progress.ts —— 与引擎无关,可直接单测
export interface WeightedStep {
  readonly name: string;
  readonly weight: number;
}

export class BootProgress {
  private readonly total: number;
  private done = 0;
  private last = 0;

  constructor(steps: readonly WeightedStep[]) {
    this.total = steps.reduce((s, x) => s + x.weight, 0);
  }

  /** 完成一步,返回 [0, 1] 的进度;保证单调不减 */
  complete(weight: number): number {
    this.done += weight;
    const raw = this.total === 0 ? 1 : this.done / this.total;
    this.last = Math.max(this.last, Math.min(1, raw));
    return this.last;
  }

  get ratio(): number {
    return this.last;
  }
}

一个容易忽略的细节:Math.min(1, raw) 要放在 Math.max 的内侧。如果权重表被改错、done 超过了 total,外层 Math.max 会让进度条停在 1 而不是冲到 1.3——ProgressBar.progress 只接受 [0, 1],越界值在编辑器里看不出问题,但会让 bar 的宽度算错。

5.1.6 平台差异:微信小游戏的启动约束

同样是「冷启动」,三端拿到的起点完全不同。判平台一律用 sys.platform,不要用 sys.isMobile 之类的近似值——sys.isMobile 在 iPad 上的微信里是 true,但平台是 WECHAT_GAME 而不是 MOBILE_BROWSER。

平台sys.platform启动期的特殊约束
H5sys.Platform.MOBILE_BROWSER / DESKTOP_BROWSER首次交互前不能播放音频;localStorage 可能被隐私模式禁用
微信小游戏sys.Platform.WECHAT_GAME有 4 MB 首包限制;wx.getUpdateManager 要先于场景加载检查更新
Android / iOS 原生sys.Platform.ANDROID / sys.Platform.IOS引擎 native 层初始化更慢;sys.localStorage 落到沙盒文件

存档键统一走一个薄封装,三端在启动期就要确定用哪条路径:

// src/platform/storage.ts —— 存档键与后端选择
import { sys } from 'cc';

export const SAVE_KEY = 'block-dash.save.v1';

export function readSave(): string | null {
  if (sys.platform === sys.Platform.WECHAT_GAME) {
    // 微信端在 SDK 适配层里换成 wx.getStorageSync
    return sys.localStorage.getItem(SAVE_KEY);
  }
  return sys.localStorage.getItem(SAVE_KEY);
}

微信端的实际实现会在 11.1 本地存档与版本迁移 里展开,本节只需要把「启动期读一次存档」这个动作放进 BootFlow 的 configs 步骤之前——如果存档能读到,就直接跳过新手引导,省掉一段没必要的资源预取。

5.1.7 工程收口

这一节做完,工程里多了这些确定的东西:

  • assets/scenes/boot.scene:启动场景,Canvas 下只有 ProgressBar 与 VersionLabel。
  • assets/scripts/core/boot-progress.ts:加权进度算法,无 cc 依赖,可单测。
  • assets/scripts/game/boot-flow.ts:BootFlow 组件,串起三步预取并驱动进度条。
  • assets/scripts/platform/storage.ts:SAVE_KEY 与三端读取入口。
  • 工程设置里「启动场景」从 main 改为 boot(构建面板 → 起始场景)。

下一节处理 boot 把玩家送进 main 之后的事:主场景与关卡场景之间怎么切、切之前怎么把下一关的资源提前拉好、以及为什么裸切场景会黑一帧。

阅读导航:上一节:目录 · 下一节:5.2 场景切换与预加载 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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