PixiJS 修炼指南 - 05. 场景管理

不知道大家是否还记得,在第二篇《PixiJS 修炼指南 - 02. 项目重构》中,我们创建第一个场景时曾经声明了一个名为 IScene 的场景接口,今天让我们开始实现场景管理器把它给用起来。

场景管理器

创建

资源管理模块 (AssetsManager) 一样,我们同样以静态类的形式创建一个 场景管理器 (SceneManager) 出来:

1
2
3
4
5
6
7
8
9
10
// src/services/scene-manager.ts
import { Container } from 'pixi.js';

/** 场景管理器 */
export class SceneManager {
private constructor() {
// 构造函数私有化,避免被外部实例化出新对象
throw new Error('请勿调用此构造函数');
}
}

然后,添加 approotcurrentScene 三个基本成员,控制可访问性使它们对外只读:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
export class SceneManager {
// ...

/** 当前应用 */
private static $app: Application | null = null;
/** 当前应用 (Public Read-only) */
public static get app() {
return this.$app;
}

/** 场景根节点 */
private static $root: Container | null = null;
/** 场景根节点 (Public Read-only) */
public static get root() {
return this.$root;
}

/** 当前场景 */
private static $currentScene: IScene | null = null;
/** 当前场景 (Public Read-only) */
public static get currentScene() {
return this.$currentScene;
}

/** 当前应用是否运作中 */
public static get isAppRunning() {
return !!this.app?.renderer;
}
}

接着提供一个 initialize() 方法用于初始化配置上面的属性,并且在初始化时为 app.ticker 绑定回调,在这里调用当前场景的 tick 动画帧更新方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
interface ISceneManagerInitializeOptions {
/** 当前应用 */
app: Application;
/** 场景根节点 */
root?: Container;
}

export class SceneManager {
// ...

/**
* 初始化场景管理器
* @param options 初始化参数
*/
static initialize(options: ISceneManagerInitializeOptions) {
const { app } = options;
const { stage: appStage } = app;
const { root = appStage } = options;
if (root !== appStage) {
appStage.addChild(root);
}

this.$app = app;
this.$root = root;

app.ticker.add(this.onUpdate.bind(this));
// this.onResize();
}

private static onUpdate(delta: number) {
if (!this.isAppRunning) return;
this.$currentScene?.update?.(delta);
}
}

这里的 onUpdate 作用我们在之后的场景动画篇章里再细说,目前可以先当作占位处理。

最后,我们增加一个用于切换场景的方法 changeScene()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
export class SceneManager {
// ...

/** 检查是否初始化完毕 */
private static checkIfInitialized() {
if (!this.$app) {
throw new Error('SceneManager 尚未初始化!');
}
if (!this.isAppRunning) {
throw new Error('当前应用可能已停止运行被回收,无法再渲染');
}
}

/**
* 切换场景
* @param newScene 新场景
* @param options 场景进场参数
*/
static changeScene(newScene: IScene, options?: ISceneOptions) {
this.checkIfInitialized();
const {
currentScene: oldScene,
} = this;
if (oldScene) {
this.root?.removeChild(oldScene);
oldScene.destroy({ children: true });
}
this.$currentScene = newScene;
this.root?.addChild(newScene);
// this.resizeCurrentScene();
newScene.onEnter?.(options ?? {});
}
}

changeScene() 方法中,我们主要做的事情就是确认场景管理器的可用状态后,将旧场景 oldScene 从场景根节点移除并销毁。

再将跳转到的新场景 newScene 挂载到根节点上,并且触发新场景的 onEnter() 生命周期钩子,对其传入新场景的进场参数。

应用于 DEMO

目前我们 DEMO 项目的 src/ 目录结构不出意外的话,应该大致是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
src
├─configs
│ └─assets-config.ts
├─scenes
│ └─first-screen.ts
├─services
│ ├─assets-manager.ts
│ └─scene-manager.ts
├─types
│ └─scene.d.ts
├─app.ts
└─main.ts

我们在 src/scenes/ 目录内创建一个启动场景 boot-loader.ts,并将 app.ts 内的 startGame() 改为切换到此场景,并约定一个 onAssetsLoaded 回调钩子,在这个回调里切换到 FirstScene 起始场景:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// src/app.ts
import { BootLoader } from '../scenes/boot-loader.ts';

export default class MyApp extends Application {
constructor() {
super({
width: 640,
height: 360,
backgroundColor: 0x6495ed,
});
}

async startGame() {
const bootLoader = new BootLoader({
onAssetsLoaded: () => {
// 创建起始场景
const firstScene = new FirstScene({
app: this,
});
SceneManager.changeScene(firstScene);
},
});
SceneManager.changeScene(bootLoader);
}
}

启动场景 boot-loader.ts 结构比较简单,目前就是一个基本的进度文案提示。

创建启动场景后,场景内将调用 AssetsManager 进行资源加载。加载过程中定期更新加载进度展示,完成后触发 onAssetsLoaded 回调:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// src/scenes/boot-loader.ts
import { Container, Text } from 'pixi.js';

import { AssetsManager } from '@/services/assets-manager';

const LOG_HEADER = '[boot-loader]';

export class BootLoader extends Container implements IScene {
private txtProgress: Text;
private onAssetsLoaded;

constructor(options: { onAssetsLoaded: () => void }) {
super();
this.onAssetsLoaded = options.onAssetsLoaded;

// 创建文本成员
this.txtProgress = new Text('', {
fontSize: 32,
fontWeight: '900',
fill: 0xffffff,
});
this.addChild(this.txtProgress);

this.startLoad();
}

private async startLoad() {
// 开始加载
await AssetsManager.init({
onProgress: (progress) => {
// 进度回调
const {
packLoaded,
packProgress,
packTotalCount,
} = progress;
const totalPer = (packLoaded + packProgress) / packTotalCount;
console.log(LOG_HEADER, '加载进度:', totalPer, progress);

// 更新文本展示
const progressText = `加载进度: ${(totalPer * 100).toFixed(2)}`;
this.txtProgress.text = progressText;
},
});
// 加载完毕,触发回调
this.onAssetsLoaded();
}
}

Boot Loader 效果

这样,我们就完成了一个用于资源加载时展示的 启动等待场景 (BootLoader) ,并实现了加载完毕后通过 场景管理器 (SceneManager) 切换到 起始场景 (FirstScene) 的转场流程。

场景写法优化

场景成员整理

上面的 BootLoader 启动等待场景内,我们只使用到一个 Text 成员用于文本展示,实际项目中的场景肯定远非这么一两个小虾米就能搞定的,场景内用到的成员可能会达到几十甚至上百的数量。

这个情况下,直接将场景成员作为场景类的一级字段进行存放,结构难免将会越来越凌乱,甚至可能出现成员命名和场景的方法或者基类成员名字撞车的情况。

因此,我们推荐将场景的成员统一放入一个 members 字段,并约定其成员类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 【增加】场景成员类型
interface IBootLoaderMembers {
txtProgress: Text;
}

export class BootLoader extends Container implements IScene {
// 【增加】场景成员字段
private members: IBootLoaderMembers;

constructor(options: { onAssetsLoaded: () => void }) {
super();

this.members = this.createMembers();
// ...
}

// 【增加】场景成员创建
private createMembers() {
const txtProgress = new Text('', {
fontSize: 32,
fontWeight: '900',
fill: 0xffffff,
});

this.addChild(txtProgress);

return {
txtProgress,
};
}
}

这样,如果成员类型发生增减并不会影响场景的一级字段和 constructor 构造函数内的代码复杂度,并且在创建和使用到场景成员的地方都能得到可用成员的类型提示辅助,便于开发时快速获取可用的场景成员。

Hint 06

场景成员事件

如果我们创建的成员还有自己的事件回调,相关绑定处理的代码也可以提取出来,这里建议收拢书写为一个 events 字段处理。

比如,我们在启动等待场景内添加一个退出按键,设定对应的事件模式后,通过 on() 方法绑定对应的点击回调函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
interface IBootLoaderMembers {
txtProgress: Text;
btnExit: Sprite;
}

export class BootLoader extends Container implements IScene {
private members: IBootLoaderMembers;

constructor(options: { onAssetsLoaded: () => void }) {
super();

this.members = this.createMembers();
// 【增加】绑定场景事件
this.bindEvents();
// ...
}

private createMembers() {
// ...

const btnExit = new Sprite(/* ... */);

return {
// ...
btnExit,
};
}

// 【增加】绑定场景事件
private bindEvents() {
this.members.btnExit.eventMode = 'static';
this.members.btnExit.on('pointerdown', this.events.onClickExit);
}

// 【增加】可用场景事件
private events = {
onClickExit: () => {
// TODO: 退出游戏
},
};
}`

bindEvents() 方法内,我们再对场景内的成员进行事件绑定,这样创建成员时的定位、样式等调整代码,与回调事件的处理代码就不会混杂在一起;事件回调处理与场景的操作方法、生命周期钩子也不会混杂,互相之间的界线清晰明了。

Hint 07

相关资料:

提取子组件

要控制场景的结构复杂度,除了上面的代码整理之外,对于较庞大的结构还需要抽取成为独立的场景子组件,再创建添加到当前场景内:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// src/scenes/boot-loader.ts
import { FancyBackground } from './boot-loader/fancy-background.ts';

interface IBootLoaderMembers {
// 【增加】场景子组件:背景
fancyBackground: FancyBackground;
txtProgress: Text;
}

export class BootLoader extends Container implements IScene {
private members: IBootLoaderMembers;

constructor(options: { onAssetsLoaded: () => void }) {
super();

this.members = this.createMembers();
// ...
}

private createMembers() {
// ...

// 【增加】创建背景子组件,并加入本场景
const fancyBackground = new FancyBackground();
this.addChild(fancyBackground);

return {
// ...
fancyBackground,
};
}
}

对于背景子组件,也可以使用上面的 membersevents 代码组织模式来进行书写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// src/scenes/boot-loader/fancy-background.ts
interface IFancyBackgroundMembers {
// ...
}

export class FancyBackground extends Container {
private members: IFancyBackgroundMembers;

constructor() {
super();

this.members = this.createMembers();
}

private createMembers() {
// ...
return {
// ...
};
}
}

这样将复杂的场景模块作为独立的子组件拆分出来独立自治,单独维护自身的子成员和事件处理,就不会影响所在场景的一阶逻辑复杂度了。

子组件事件通信

当我们的子组件需要向它所属的场景或者其他父组件传递信息时,就需要用到事件通信了。

比如我们刚才为退出按键绑定的 pointerdown 事件回调函数,其实就是 PixiJS 的 DisplayObject 内部提供了一套基本的交互事件中的其中之一。

在组件内部通过 emit() 方法来发送事件,在组件外就能像上面一样通过 on() 方法进行回调监听了:

Hint 08

如果我们需要发送上面的事件名单之外的事件,比如我们创建了一个虚拟键盘组件,用户点击某个按键之后,我们向上级组件送出一个 virtual-key-down 事件,直接调用组件的 emit() 方法就会遇到 TypeScript 的报错——我们新增的事件并不在默认的 DisplayObject 通用事件列表内:

Hint 09

这个问题比较简单的解决方案,就是声明虚拟键盘的自定义事件接口 IVirtualKeyboardEvents,然后用它为我们的虚拟键盘类创建一个自定义事件对象成员 customEvents,之后只需要将自定义事件的发送和监听都交给这个成员来处理。

这个成员在组件的内部和外部都不会——也不应该——被修改,所以可以直接设置为 readonly 完全只读访问:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
import { EventEmitter } from '@pixi/utils';

interface IVirtualKeyboardMembers {
keys: Record<string, Sprite>;
}

interface IVirtualKeyboardEvents {
'virtual-key-down': (ev: { keyCode: string, originEvent: FederatedPointerEvent }) => void;
}

export class VirtualKeyboard extends Container {
/** 组件成员 */
private members: IVirtualKeyboardMembers;
/** 自定义事件对象 */
readonly customEvents = new EventEmitter<IVirtualKeyboardEvents>();

constructor() {
super();

this.members = this.createMembers();
this.bindEvents();
}

private createMembers() {
const keys: Record<string, Sprite> = {};

keys.a = new Sprite();
// ...

return {
keys,
};
}

private bindEvents() {
const { keys } = this.members;

Object.entries(keys).forEach(([keyCode, key]) => {
// 绑定每个按键的点击事件
key.eventMode = 'static';
key.on('pointerdown', (ev) => {
this.events.onClickKey(keyCode, ev);
});
});
// ...
}

private events = {
onClickKey: (keyCode: string, ev: FederatedPointerEvent) => {
// 对外发送封装的自定义事件 virtual-key-down
this.customEvents.emit('virtual-key-down', {
keyCode,
originEvent: ev,
});
},
};
}

这样就能正常发送我们自定义的新事件了。

而在上级组件内对这个自定义事件进行监听,绑定回调时也可以直接获得对应的类型检查和智能提示:

Hint 10

小结

这次我们只实现了场景管理器的 转场控制 能力,没有什么复杂内容,只是完成了一个通用流程的提取,所以后面补充了一点场景写法上的建议。

之后我们将在此基础上,说说场景动画的控制以及画面尺寸适配的问题。

欢迎大家点赞收藏关注,期待下篇文章再见~