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 | 启动期的特殊约束 |
|---|---|---|
| H5 | sys.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 场景切换与预加载 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。