IT博客汇
  • 首页
  • 精华
  • 技术
  • 设计
  • 资讯
  • 扯淡
  • 权利声明
  • 登录 注册

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

    krimeshu发表于 2023-08-27 05:13:46
    love 0

    潜在问题

    在上一篇最后的例子中,我们写了一段代码实现一个简单的静态场景 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;
    };

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

    而且事实上因为我们使用 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 接口的约定,为之后实现场景管理器做好准备。

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

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



沪ICP备19023445号-2号
友情链接