PixiJS 修炼指南 - 02. 项目重构
潜在问题
在上一篇最后的例子中,我们写了一段代码实现一个简单的静态场景 demo,它跑起来了而且目前看起来也不复杂——从上到下写过来的代码,可以说是相当地“平铺直叙”了。
但是设想一下,随着项目开发的正式启动,其中的场景成员越来越多可能达到几十甚至上百以计的规模。而且通常游戏都不会只有一个场景,每个场景、成员之间的控制和回调代码相互交织,结果显然将会变成一团混乱的面条代码,彻底走向失控。
所以我们通常推荐在项目跑起来后,通过面向对象的方式将代码进行抽象归类,再通过启动入口、场景管理器等核心部分进行统一调度管理。以合理的代码组织方式进行项目重构,来达到各部分之间的界限清晰、分工明确的效果,确保项目的可维护性。
结构梳理
1. 启动代码
一般而言,我们的项目会有一个固定入口,我们会在其中进行项目启动时的初始化设定,这里的代码相对固定,而且不需要在具体业务逻辑中复用,我们将它们划分为 启动代码 (Bootstrap)。
我们通常会在这里做以下事情:
- 配置插件:引入插件,设置插件参数;
- 补丁与HACK:引入补丁/HACK 处理模块;
- 项目初始化:引入基础样式、初始化公共模块;
- 创建应用:决定启动参数,创建应用的实例;
- 创建核心对象:配置场景管理器等核心对象;
- 全局事件:监听全局事件(如页面尺寸变化),通知应用进行处理;
- 启动应用:串联各部分流程,启动进入初始场景(一般是资源加载场景)。
2. 场景管理器
所有场景代码存放在各自的文件或目录内,将会存在很多份。但是场景之间的切换调度、缩放适配等逻辑只需要存在一份,而且这些逻辑内部关系较为紧密,所以我们将其提取出来,作为一个核心模块—— 场景管理器 (SceneManager)。
这个模块我们因为只需要存在一份实例,所以我们之后会将其作为静态类来实现,达到 不需要实例化、跟随应用全局的生命周期存在、业务代码内引入即可用 的效果,让之后的业务代码编写更加方便快捷。
3. 业务代码
对于每个不同的场景,我们将它们和内部的场景成员放在单独的文件或目录内进行开发。
每个场景自身的代码逻辑内部聚合,开发时按照推荐模式进行代码组织,这样在团队合作中就能更快速的理清场景代码结构,提高合作效率和之后的项目可维护性。
4. 结构图
上面说的几个部分间,大致可以简单理解成这样的引用关系:
业务代码开发模式
1. 场景成员与面向对象
在我们的游戏过程中,各个场景和它们内部成员,都会按照具体情况反复创建和销毁,而且像是场景成员还有可能同时有多个实例存在。
所以我们通常不会一个个 new 出成员后再逐个动态调整它们的属性和方法。而是采用面向对象的开发模式,先根据我们的需求创建出具有定制的属性、方法的类,之后就能随时地将这些类进行实例化 new 出需要的数量,随时将它们 加入场景、监听回调、操作控制 或是 销毁回收。
(1) 日常开发情形:为某类成员添加操作方法
比如上一篇中,我们在 demo 里直接通过 Sprite.from()
这样类似 new Sprite()
的“创建后再动态调整”的方式可以完成简单的需求开发,看起来似乎没什么问题:
1 | // 创建精灵成员 |
但如果我们需要给它增加左右移动的方法时,就需要这样来实现了:
1 | // 方法:向左移动 |
这时候,如果我们需要继续创建多个相同的精灵成员实例,就需要给每个成员都进行 moveLeft
和 moveRight
的“方法动态补完”处理,效率低下,而且代码零散。
而且事实上因为我们使用 TypeScript 开发,这样的代码将会直接报错:
1 | - 类型“Sprite”上不存在属性“moveLeft”。ts(2339) |
因为 TypeScript 作为强类型语言,并不允许在运行过程中动态地直接进行类型修改——毕竟静态类型检查无法预测这样的修改情况。
只能通过函数的形式来操作:
1 | // 外部操作函数:向左移动 |
但这样通过外部函数访问,只能操作到对象的公开属性,无法访问私有属性,影响封装效果。而且这种写法,无法直接通过对象成员的形式进行智能提示的辅助开发,显然不是个好办法。
(2) 通过面向对象改进实现
这里推荐的写法是,将“可以移动的精灵成员”写成一个由 Sprite
派生的类 MovableSprite
:
1 | // movable-sprite.ts |
这样一来,需要创建更多成员的时候只要直接 new MovableSprite()
就行了。
而且每个 MovableSprite
示例都会自动加载纹理素材、设置锚点,并自动拥有定义好的 moveLeft()
/ moveRight()
方法可供直接调用。
在入口脚本使用它时的例子:
1 | // main.ts |
对于需要在销毁时回收资源的类,还可以重写 destroy()
方法,实现整个场景销毁时自动释放成员内对应资源的引用,确保不会再使用到的资源能被JS引擎垃圾回收,释放出占用的内存。
我们只需要在类里写下 destroy
的前面部分,VSCode 就会给出重载 destroy()
的智能提示:
这时候只需要光标切换到需要重载的方法位置上,按下回车键即可自动生成需要重载的方法格式。然后我们只需要在这个基础上再做调整,加上基类同名方法调用后,继续补充我们需要的销毁前资源释放处理就行了:
1 | export default class MovableSprite extends Sprite { |
2. 场景
刚刚说完了场景成员,现在该来看看场景了——所谓场景,其实就是用来容纳场景成员的容器。
所以我们通过继承 PixiJS 的 Container
类来创建场景即可。
不过除了容器本身的性质之外,场景一般还会有一些需要实现的特性:
- 跟随应用 ticker 进行场景刷新;
- 屏幕尺寸变化时,调整内部成员布局;
- 销毁容器时,连带销毁内部成员。
这里我们通过全局的 type 定义文件内创建一个接口的方式来做约束。
(1) 场景接口
我们先在项目的 src/ 目录下新增一个 types/ 目录,然后在里面新建一个文件,名字改为 scene.d.ts,内容为:
1 | // src/types/scene.d.ts |
这样我们就完成了一个名为 IScene
的场景约定接口,它要求实现该接口的类需要继承于 Container,然后还提供了 onEnter()
, update()
, onResize()
三个可选回调方法。
和之前的 destroy()
一样,我们需要重载这三个可选回调时,也可以通过智能提示来快速创建基本代码:
这三个方法的具体作用我们之后结合具体情况再细说,目前可以说只是先占个位。
(2) 第一个场景
接下来,我们再创建一个 src/scenes/ 目录,之后我们的所有场景都放在这个目录下。
比如现在我们创建一个 first-scene.ts,将之前入口脚本的简单场景内容转移到这里:
1 | // src/scenes/first-scene.ts |
这样就完成了我们第一个场景的定义,之后需要创建它时,只要随时 new FirstScene()
就行了。
3. 应用与启动脚本
同样的,我们的应用对象也使用这个方式从 PixiJS 默认的 Application
中派生出来,这里取名就直接取名为“我的应用” (MyApp
) 吧:
1 | // app.ts |
最后终于回到入口位置的脚本,我们只需要这样创建和启动刚才的应用:
1 | // main.ts |
至此,我们的项目重构工作算是暂时告一段落了。
完成这一切后,重新跑起来的项目效果看起来与之前相比,其实并不会有什么明显区别。
但是只要打开项目内部的文件查看,就会发现之前全部堆积在一起的代码已经井井有条:
- 入口脚本 main.ts 代码简洁,并且预留了以后启动项目时的调整位置;
- 顶层的 app.ts 应用内,不需要关注细枝末节的场景成员实现,顶部庞大的
import
只剩下引入基类Application
和初始场景FirstScene
,清晰明了; - 场景和成员之间的代码也是泾渭分明,比如
FirstScene
内使用MovableSprite
时,就不需要关注内部的材质加载、锚点设定、移动方法实现等细节,只要使用即可; - 通过
IScene
接口的约定,为之后实现场景管理器做好准备。
如此一来,内部代码的可读性、可拓展性和可维护性都得到了质的提升,为之后的开发工作算是打了个相对稳固的基础。
之后我们将会再结合场景成员类型与事件管理、资源预加载、画面适配、场景动画和过渡动画等更多例子,继续完善这个项目结构,敬请期待~