PixiJS 修炼指南 - 02. 项目重构

潜在问题

在上一篇最后的例子中,我们写了一段代码实现一个简单的静态场景 demo,它跑起来了而且目前看起来也不复杂——从上到下写过来的代码,可以说是相当地“平铺直叙”了。

但是设想一下,随着项目开发的正式启动,其中的场景成员越来越多可能达到几十甚至上百以计的规模。而且通常游戏都不会只有一个场景,每个场景、成员之间的控制和回调代码相互交织,结果显然将会变成一团混乱的面条代码,彻底走向失控

所以我们通常推荐在项目跑起来后,通过面向对象的方式将代码进行抽象归类,再通过启动入口、场景管理器等核心部分进行统一调度管理。以合理的代码组织方式进行项目重构,来达到各部分之间的界限清晰、分工明确的效果,确保项目的可维护性。

结构梳理

1. 启动代码

一般而言,我们的项目会有一个固定入口,我们会在其中进行项目启动时的初始化设定,这里的代码相对固定,而且不需要在具体业务逻辑中复用,我们将它们划分为 启动代码 (Bootstrap)。

我们通常会在这里做以下事情:

  • 配置插件:引入插件,设置插件参数;
  • 补丁与HACK:引入补丁/HACK 处理模块;
  • 项目初始化:引入基础样式、初始化公共模块;
  • 创建应用:决定启动参数,创建应用的实例;
  • 创建核心对象:配置场景管理器等核心对象;
  • 全局事件:监听全局事件(如页面尺寸变化),通知应用进行处理;
  • 启动应用:串联各部分流程,启动进入初始场景(一般是资源加载场景)。

2. 场景管理器

所有场景代码存放在各自的文件或目录内,将会存在很多份。但是场景之间的切换调度缩放适配等逻辑只需要存在一份,而且这些逻辑内部关系较为紧密,所以我们将其提取出来,作为一个核心模块—— 场景管理器 (SceneManager)。

这个模块我们因为只需要存在一份实例,所以我们之后会将其作为静态类来实现,达到 不需要实例化、跟随应用全局的生命周期存在、业务代码内引入即可用 的效果,让之后的业务代码编写更加方便快捷。

3. 业务代码

对于每个不同的场景,我们将它们和内部的场景成员放在单独的文件或目录内进行开发。

每个场景自身的代码逻辑内部聚合,开发时按照推荐模式进行代码组织,这样在团队合作中就能更快速的理清场景代码结构,提高合作效率和之后的项目可维护性。

4. 结构图

上面说的几个部分间,大致可以简单理解成这样的引用关系:

项目结构

业务代码开发模式

1. 场景成员与面向对象

在我们的游戏过程中,各个场景和它们内部成员,都会按照具体情况反复创建和销毁,而且像是场景成员还有可能同时有多个实例存在。

所以我们通常不会一个个 new 出成员后再逐个动态调整它们的属性和方法。而是采用面向对象的开发模式,先根据我们的需求创建出具有定制的属性、方法的类,之后就能随时地将这些类进行实例化 new 出需要的数量,随时将它们 加入场景、监听回调、操作控制 或是 销毁回收

(1) 日常开发情形:为某类成员添加操作方法

比如上一篇中,我们在 demo 里直接通过 Sprite.from() 这样类似 new Sprite() 的“创建后再动态调整”的方式可以完成简单的需求开发,看起来似乎没什么问题:

1
2
3
4
// 创建精灵成员
const sprite = Sprite.from('https://hk.krimeshu.com/public/images/sprite-minion.png');
sprite.anchor.set(0.5, 0.5);
sprite.position.set(app.screen.width / 2, app.screen.height / 2);

但如果我们需要给它增加左右移动的方法时,就需要这样来实现了:

1
2
3
4
5
6
7
8
// 方法:向左移动
sprite.moveLeft = function (distance = 1) {
this.x -= distance;
};
// 方法:向左移动
sprite.moveRight = function (distance = 1) {
this.x += distance;
};

这时候,如果我们需要继续创建多个相同的精灵成员实例,就需要给每个成员都进行 moveLeftmoveRight 的“方法动态补完”处理,效率低下,而且代码零散。

而且事实上因为我们使用 TypeScript 开发,这样的代码将会直接报错:

1
2
- 类型“Sprite”上不存在属性“moveLeft”。ts(2339)
- 类型“Sprite”上不存在属性“moveRight”。ts(2339)

因为 TypeScript 作为强类型语言,并不允许在运行过程中动态地直接进行类型修改——毕竟静态类型检查无法预测这样的修改情况。

只能通过函数的形式来操作:

1
2
3
4
5
6
7
8
// 外部操作函数:向左移动
const moveLeft = (sprite: Sprite, distance = 1) => {
sprite.x -= distance;
};
// 外部操作函数:向右移动
const moveRight = (sprite: Sprite, distance = 1) => {
sprite.x += distance;
};

但这样通过外部函数访问,只能操作到对象的公开属性,无法访问私有属性,影响封装效果。而且这种写法,无法直接通过对象成员的形式进行智能提示的辅助开发,显然不是个好办法。

(2) 通过面向对象改进实现

这里推荐的写法是,将“可以移动的精灵成员”写成一个由 Sprite 派生的类 MovableSprite:

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
// movable-sprite.ts
import { Assets, Sprite, Texture } from 'pixi.js';

export default class MovableSprite extends Sprite {
constructor() {
super();

this.init();
}

async init() {
const texture = await Assets.load('https://hk.krimeshu.com/public/images/sprite-minion.png') as Texture;
this.texture = texture;
this.anchor.set(0.5);
}

/** 向左移动 */
moveLeft(distance = 1) {
this.x -= distance;
}

/** 向右移动的 */
moveRight(distance = 1) {
this.x += distance;
}
}

这样一来,需要创建更多成员的时候只要直接 new MovableSprite() 就行了。

而且每个 MovableSprite 示例都会自动加载纹理素材、设置锚点,并自动拥有定义好的 moveLeft() / moveRight() 方法可供直接调用。

在入口脚本使用它时的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// main.ts
import MovableSprite from './movable-sprite';

// ...

const sprite1 = new MovableSprite();
sprite1.position.set(app.screen.width / 2, app.screen.height / 2);
sprite1.moveLeft(80); // 1号精灵左移80px

const sprite2 = new MovableSprite();
sprite2.position.set(app.screen.width / 2, app.screen.height / 2);
sprite2.moveRight(80); // 2号精灵右移80px

app.stage.addChild(sprite1, sprite2);

Demo 02

对于需要在销毁时回收资源的类,还可以重写 destroy() 方法,实现整个场景销毁时自动释放成员内对应资源的引用,确保不会再使用到的资源能被JS引擎垃圾回收,释放出占用的内存。

我们只需要在类里写下 destroy 的前面部分,VSCode 就会给出重载 destroy() 的智能提示:

Hint 01

这时候只需要光标切换到需要重载的方法位置上,按下回车键即可自动生成需要重载的方法格式。然后我们只需要在这个基础上再做调整,加上基类同名方法调用后,继续补充我们需要的销毁前资源释放处理就行了:

1
2
3
4
5
6
7
8
9
export default class MovableSprite extends Sprite {
// ...

destroy(options?: boolean | IDestroyOptions | undefined): void {
// 调用基类的 destroy 方法,保留原有的销毁流程
super.destroy(options);
// TODO: 释放我们新增的资源引用...
}
}

2. 场景

刚刚说完了场景成员,现在该来看看场景了——所谓场景,其实就是用来容纳场景成员的容器。

所以我们通过继承 PixiJS 的 Container 类来创建场景即可。

不过除了容器本身的性质之外,场景一般还会有一些需要实现的特性:

  • 跟随应用 ticker 进行场景刷新;
  • 屏幕尺寸变化时,调整内部成员布局;
  • 销毁容器时,连带销毁内部成员。

这里我们通过全局的 type 定义文件内创建一个接口的方式来做约束。

(1) 场景接口

我们先在项目的 src/ 目录下新增一个 types/ 目录,然后在里面新建一个文件,名字改为 scene.d.ts,内容为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// src/types/scene.d.ts
import type { Container } from 'pixi.js';

declare global {
interface IScene extends Container {
/**
* 进入当前场景时的回调
* @param parameters 场景参数
*/
onEnter?(parameters: Record<string, string | number | boolean | null>): void;
/**
* 更新场景的 tick 回调
* @param delta 距离上次回调过去的帧数
*/
update?(delta: number): void;
/**
* 界面尺寸改变时的回调
*/
onResize?(): void;
}
}

这样我们就完成了一个名为 IScene 的场景约定接口,它要求实现该接口的类需要继承于 Container,然后还提供了 onEnter(), update(), onResize() 三个可选回调方法。

和之前的 destroy() 一样,我们需要重载这三个可选回调时,也可以通过智能提示来快速创建基本代码:

Hint 02

这三个方法的具体作用我们之后结合具体情况再细说,目前可以说只是先占个位。

(2) 第一个场景

接下来,我们再创建一个 src/scenes/ 目录,之后我们的所有场景都放在这个目录下。

比如现在我们创建一个 first-scene.ts,将之前入口脚本的简单场景内容转移到这里:

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
// src/scenes/first-scene.ts
import { Application, Container } from 'pixi.js';

import MovableSprite from './public/movable-sprite';

export default class FirstScene extends Container implements IScene {
constructor(options: { app: Application }) {
// 调用基类构造函数,完成基础初始化
super();

// 创建本场景成员
this.createMembers(options.app);
}

/**
* 创建本场景成员
* @param app 所属应用实例
*/
createMembers(app: Application) {
const sprite1 = new MovableSprite();
sprite1.position.set(app.screen.width / 2, app.screen.height / 2);
sprite1.moveLeft(80); // 1号精灵左移80px

const sprite2 = new MovableSprite();
sprite2.position.set(app.screen.width / 2, app.screen.height / 2);
sprite2.moveRight(80); // 2号精灵右移80px

this.addChild(sprite1, sprite2);
}
}

这样就完成了我们第一个场景的定义,之后需要创建它时,只要随时 new FirstScene() 就行了。

3. 应用与启动脚本

同样的,我们的应用对象也使用这个方式从 PixiJS 默认的 Application 中派生出来,这里取名就直接取名为“我的应用” (MyApp) 吧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// app.ts
import { Application } from 'pixi.js';

import FirstScene from './scenes/first-scene';

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

startGame() {
// 创建起始场景
const firstScene = new FirstScene({
app: this,
});
this.stage.addChild(firstScene);
}
}

最后终于回到入口位置的脚本,我们只需要这样创建和启动刚才的应用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// main.ts
import MyApp from './app';

// TODO: 配置插件...
// TODO: 补丁与HACK...
// TODO: 项目初始化...

// 创建应用
const app = new MyApp();
document.body.appendChild(app.view as HTMLCanvasElement);

// TODO: 创建场景管理器...
// TODO: 全局事件监听...

// 启动应用
app.startGame();

至此,我们的项目重构工作算是暂时告一段落了。


完成这一切后,重新跑起来的项目效果看起来与之前相比,其实并不会有什么明显区别。

但是只要打开项目内部的文件查看,就会发现之前全部堆积在一起的代码已经井井有条:

  • 入口脚本 main.ts 代码简洁,并且预留了以后启动项目时的调整位置;
  • 顶层的 app.ts 应用内,不需要关注细枝末节的场景成员实现,顶部庞大的 import 只剩下引入基类 Application 和初始场景 FirstScene,清晰明了;
  • 场景和成员之间的代码也是泾渭分明,比如 FirstScene 内使用 MovableSprite 时,就不需要关注内部的材质加载、锚点设定、移动方法实现等细节,只要使用即可;
  • 通过 IScene 接口的约定,为之后实现场景管理器做好准备。

如此一来,内部代码的可读性、可拓展性和可维护性都得到了质的提升,为之后的开发工作算是打了个相对稳固的基础。

之后我们将会再结合场景成员类型与事件管理、资源预加载、画面适配、场景动画和过渡动画等更多例子,继续完善这个项目结构,敬请期待~