Wonderland Engine 1.0.0 JavaScript 迁移

Wonderland Engine 正在对其处理、交互和分发 JavaScript 代码的方式进行大幅改进。

这篇博文将指导您了解这些变化。此外,迁移部分将详细介绍每个新功能,为您的 pre-1.0.0 项目提供迁移步骤。

动机 

截至目前,Wonderland Engine 的默认打包器依赖于连接本地脚本。用户会创建脚本,编辑器会拾取它们并打包以创建最终的应用程序。 需要预先打包任何第三方库,并将其放在项目的文件夹结构中。

或者,可以设置 NPM 项目,但设置是手动的,团队中的艺术师需要设置 NodeJS 并按照步骤安装依赖项等。

新系统有几个优点:

  • 默认支持 NPM 包依赖项
  • 更好的 IDE 自动完成建议
  • 更轻松地与高级工具(如 TypeScript)集成
  • 与其他 WebAssembly 库兼容
  • 每个页面的多个 Wonderland Engine 实例
  • 为非开发团队成员自动管理您的 NPM 项目。

我们一直在开发一个新的 JavaScript 生态系统,以帮助您无缝地与您喜爱的工具协同工作。

编辑器组件 

如果您以前是 NPM 用户,可能会遇到以下情况:

Wonderland Engine 1.0.0 JavaScript 迁移

1.0.0 版本开始,编辑器不再从应用程序包中挑选组件类型。除了修复上述错误之外, 编辑器列出了比最终应用程序中注册的组件更多的组件。 这将允许运行高级项目的用户在未来设置具有流式组件的复杂项目。

通过此更改,编辑器现在需要:

  • Views > Project Settings > JavaScript > sourcePaths 中列出组件或文件夹
  • 在根 package.json 文件中添加依赖

一个示例 package.json 暴露组件的库:

1{
2  "name": "my-wonderful-project",
3  "version": "1.0.0",
4  "description": "My Wonderland project",
5  "dependencies": {
6    "@wonderlandengine/components": "^1.0.0-rc.5"
7  }
8}

编辑器现在能够通过读取您的 package.json 文件来找到组件,以加快开发时间和提高共享性。 有关详细信息,请查看编写 JavaScript 库教程。

打包 

一个新的设置允许修改打包过程:

Views > Project Settings > JavaScript > bundlingType

Wonderland Engine 1.0.0 JavaScript 迁移

让我们看看每个选项:

esbuild 

你的脚本将使用 esbuild 打包器进行打包。

这是默认选择。出于性能原因,建议您尽可能坚持这个设置。

npm 

你的脚本将使用你自己的 npm 脚本进行打包。

示例 package.json 带有自定义 build 脚本:

 1{
 2  "name": "MyWonderfulProject",
 3  "version": "1.0.0",
 4  "description": "My Wonderland project",
 5  "type": "module",
 6  "module": "js/index.js",
 7  "scripts": {
 8    "build": "esbuild ./js/index.js --bundle --format=esm --outfile=\"deploy/MyWonderfulProject-bundle.js\""
 9  },
10  "devDependencies": {
11    "esbuild": "^0.15.18"
12  }
13}

npm 脚本名称可以在编辑器设置中设置:

Views > Project Settings > JavaScript > npmScript

Wonderland Engine 1.0.0 JavaScript 迁移

此脚本可以运行任何命令,只要它生成您的最终应用程序包即可。

您可以使用您喜欢的打包器,例如 WebpackRollup。 但是,我们建议用户使用诸如 esbuild 之类的工具来减少迭代时间。

应用程序入口点 

组件在运行时即在浏览器中运行时注册方式有所不同。

在运行时,编辑器可以自动管理您的应用程序的入口点,即 index.js 文件。

编辑器使用的模板大致如下:

 1/* wle:auto-imports:start */
 2/* wle:auto-imports:end */
 3
 4import {loadRuntime} from '@wonderlandengine/api';
 5
 6/* wle:auto-constants:start */
 7/* wle:auto-constants:end */
 8
 9const engine = await loadRuntime(RuntimeBaseName, {
10    physx: WithPhysX,
11    loader: WithLoader,
12});
13
14// ...
15
16/* wle:auto-register:start */
17/* wle:auto-register:end */
18
19engine.scene.load(`${ProjectName}.bin`);
20
21/* wle:auto-benchmark:start */
22/* wle:auto-benchmark:end */

此模板会自动复制到新创建的和旧的 pre-1.0.0 项目中。

该模板包含以下标记:

  • wle:auto-imports: 分隔应编写导入语句的地方
  • wle:auto-register: 分隔应编写注册语句的地方
  • wle:auto-constants: 分隔编辑器将编写常量的地方,例如:
    • ProjectName: 项目.wlp文件中列出的名称
    • WithPhysX: 如果启用了物理引擎则启用的布尔值
    • WithLoader: 如果需要支持 glTF 的运行时加载,则启用的布尔值

例如:

 1/* wle:auto-imports:start */
 2import {Forward} from './forward.js';
 3/* wle:auto-imports:end */
 4
 5import {loadRuntime} from '@wonderlandengine/api';
 6
 7/* wle:auto-constants:start */
 8const ProjectName = 'MyWonderland';
 9const RuntimeBaseName = 'WonderlandRuntime';
10const WithPhysX = false;
11const WithLoader = false;
12/* wle:auto-constants:end */
13
14const engine = await loadRuntime(RuntimeBaseName, {
15  physx: WithPhysX,
16  loader: WithLoader
17});
18
19// ...
20
21/* wle:auto-register:start */
22engine.registerComponent(Forward);
23/* wle:auto-register:end */
24
25engine.scene.load(`${ProjectName}.bin`);
26
27// ...

此索引文件为具有单个称为 Forward 组件的项目自动生成,定义在 js/forward.js 中。 重要的是要注意,编辑器将仅导入和注册在场景中使用的组件,即附加到对象的组件。

如果您的应用程序仅在运行时使用组件,您将需要:

  • 将它们标记为依赖。在组件依赖部分了解更多信息
  • index.js 文件中手动导入它们

对于简单的应用程序,这个模板文件就足够了,不需要修改。对于更复杂的使用情况, 您可以通过移除标记注释来自由创建和管理自己的 index.js 文件。

手动管理索引文件可以让您创建具有多个入口点的应用程序,并注册编辑器未知的组件

JavaScript 类 

Wonderland Engine 1.0.0 提供了一种声明组件的新方法:ES6 类

 1import {Component, Property} from '@wonderlandengine/api';
 2
 3class Forward extends Component {
 4    /* 组件的注册名称。 */
 5    static TypeName = 'forward';
 6    /* 在编辑器中公开的属性。 */
 7    static Properties = {
 8        speed: Property.float(1.5)
 9    };
10
11    _forward = new Float32Array(3);
12
13    update(dt) {
14        this.object.getForward(this._forward);
15        this._forward[0] *= this.speed;
16        this._forward[1] *= this.speed;
17        this._forward[2] *= this.speed;
18        this.object.translate(this._forward);
19    }
20}

有几点需要注意:

  • 不再使用全局 WL 符号,而是使用 @wonderlandengine/api 的 API
  • 我们创建了一个继承自 API Component 类的类
  • 组件的注册名称现在是一个静态属性
  • 属性在类上设置

JavaScript 属性 

对象字面量属性已被仿函数取代:

 1import {Component, Property} from '@wonderlandengine/api';
 2
 3class MyComponent extends MyComponent {
 4    /* 组件的注册名称。 */
 5    static TypeName = 'forward';
 6    /* 在编辑器中公开的属性。 */
 7    static Properties = {
 8        myFloat: Property.float(1.0),
 9        myBool: Property.bool(true),
10        myEnum: Property.enum(['first', 'second'], 'second'),
11        myMesh: Property.mesh()
12    };
13}

组件依赖 

依赖是您的组件注册时自动注册的组件。

让我们在 Forward 示例中添加一个 Speed 组件:

 1import {Component, Type} from '@wonderlandengine/api';
 2
 3class Speed extends Component {
 4    static TypeName = 'speed';
 5    static Properties = {
 6        value: Property.float(1.5)
 7    };
 8}
 9
10class Forward extends Component {
11    static TypeName = 'forward';
12    static Dependencies = [Speed];
13
14    _forward = new Float32Array(3);
15
16    start() {
17      this._speed = this.object.addComponent(Speed);
18    }
19
20    update(dt) {
21        this.object.getForward(this._forward);
22        this._forward[0] *= this._speed.value;
23        this._forward[1] *= this._speed.value;
24        this._forward[2] *= this._speed.value;
25        this.object.translate(this._forward);
26    }
27}

Forward 注册时,Speed 会因被列为依赖而自动注册。

该行为由在 index.js 中创建的 WonderlandEngine 对象上的布尔值 autoRegisterDependencies 管理。

事件 

Wonderland Engine 使用监听器数组来处理事件,例如:

  • WL.onSceneLoaded
  • WL.onXRSessionStart
  • WL.scene.onPreRender
  • WL.scene.onPreRender

引擎现在附带一个 Emitter 类来简化事件交互:

1engine.onXRSessionStart.add((session, mode) => {
2    console.log(`Start session '${mode}'!`);
3})

您可以使用标识符管理监听器:

1engine.onXRSessionStart.add((session, mode) => {
2    console.log(`Start session '${mode}'!`);
3}, {id: 'my-listener'});
4
5// 完成后,您可以简单地使用 `id` 将其移除。
6engine.onXRSessionStart.remove('my-listener');

有关更多信息,请查看 Emitter API 文档。

Object 

Object 并未在整个重做中被忽视。它经过更改,使 API 更加一致,更加安全。

导出名称 

Object 类现在导出为 Object3D。此更改是为防止对 JavaScript Object 构造函数的遮盖。

为了简化迁移,Object 仍将被导出,但请确保您现在使用

1import {Object3D} from '@wonderlandengine/api';

以便于未来的迁移。

变换 

变换 API 也未能幸免。引擎现在不鼓励使用变换的 getter & setter(访问器):

1this.object.translationLocal;
2this.object.translationWorld;
3this.object.rotationLocal;
4this.object.rotationWorld;
5this.object.scalingLocal;
6this.object.scalingWorld;
7this.object.transformLocal;
8this.object.transformWorld;

这些 getter / setter 存在一些缺点:

  • 一致性:未遵循其它变换 API
  • 性能:每次调用时分配 Float32Array
  • 安全性:内存视图可能被其他组件更改
    • 存储 Float32Array 引用以供后续读取时可能出现潜在错误

如果您渴望了解新 API,请阅读 Object Transform 部分。

JavaScript 隔离 

对于使用内部打包器的用户,您可能已经看到如以下代码:

component-a.js

1var componentAGlobal = {};
2
3WL.registerComponent('component-a', {}, {
4    init: function() {
5        comonentAGlobal.init = true;
6    },
7});

component-b.js

1WL.registerComponent('component-b', {}, {
2    init: function() {
3        if(componentAGlobal.init) {
4              console.log('Component A has been initializaed before B!');
5        }
6    },
7});

上述代码对 componentAGlobal 变量做出了一些假设。它期望 component-a 先行注册并在打包中被预置。

过去,这种方式工作是因为 Wonderland Engine 内部打包器不执行隔离

1.0.0 中,无论您使用 esbuild 还是 npm,这都将不再有效。打包器将无法将 component-a 中使用的 componentAGlobalcomponent-b 中使用的关联起来。

简而言之:在使用打包器时,把每个文件都视为隔离

迁移 

根据您的项目是否使用 npm,可能需要一些手动迁移步骤。

每个部分将描述根据您以前的设置所需的适当步骤。

编辑器组件 (#migration-editor-components) 

内部打包器 

对于以前使用内部打包器的用户,即 useInternalBundler 复选框处于激活状态:

Views > Project Settings > JavaScript > useInternalBundler

不需要 任何进一步步骤

Npm 

https://www.npmjs.com/package/wle-js-upgrade 对于 npm 用户,您需要确保您的脚本列在 sourcePaths 设置中。

如果您使用的是库,请确保该库已按照 编写 JavaScript 库 教程迁移到 Wonderland Engine 1.0.0

如果您的依赖项之一没有更新,您可以在 sourcePaths 设置中添加 node_modules 文件夹的本地路径。示例:

Wonderland Engine 1.0.0 JavaScript 迁移

请始终记住,通过 npmesbuild 生成的打包文件将不再用于在编辑器中查找组件。它仅在运行您的应用程序时使用。

打包 

不需要 任何进一步步骤。项目应自动迁移。

JavaScript 类、属性和事件 

对于所有用户,无论您以前是否启用了 useInternalBundler,这一部分是相同的。

让我们来看一些比较旧方式与新方式的代码:

1.0.0 之前

 1WL.registerComponent('player-height', {
 2    height: {type: WL.Type.Float, default: 1.75}
 3}, {
 4    init: function() {
 5        WL.onXRSessionStart.push(this.onXRSessionStart.bind(this));
 6        WL.onXRSessionEnd.push(this.onXRSessionEnd.bind(this));
 7    },
 8    start: function() {
 9        this.object.resetTranslationRotation();
10        this.object.translate([0.0, this.height, 0.0]);
11    },
12    onXRSessionStart: function() {
13        if(!['local', 'viewer'].includes(WebXR.refSpace)) {
14            this.object.resetTranslationRotation();
15        }
16    },
17    onXRSessionEnd: function() {
18        if(!['local', 'viewer'].includes(WebXR.refSpace)) {
19            this.object.resetTranslationRotation();
20            this.object.translate([0.0, this.height, 0.0]);
21        }
22    }
23});

1.0.0 之后

 1/* 不要忘记我们现在使用 npm 依赖 */
 2import {Component, Property} from '@wonderlandengine/api';
 3
 4export class PlayerHeight extends Component {
 5    static TypeName = 'player-height';
 6    static Properties = {
 7        height: Property.float(1.75)
 8    };
 9
10    init() {
11        /* Wonderland Engine 1.0.0 正在放弃使用全局实例。
12         * 现在可以通过 `this.engine` 访问当前引擎实例。 */
13        this.engine.onXRSessionStart.add(this.onXRSessionStart.bind(this));
14        this.engine.onXRSessionEnd.add(this.onXRSessionEnd.bind(this));
15    }
16    start() {
17        this.object.resetTranslationRotation();
18        this.object.translate([0.0, this.height, 0.0]);
19    }
20    onXRSessionStart() {
21        if(!['local', 'viewer'].includes(WebXR.refSpace)) {
22            this.object.resetTranslationRotation();
23        }
24    }
25    onXRSessionEnd() {
26        if(!['local', 'viewer'].includes(WebXR.refSpace)) {
27            this.object.resetTranslationRotation();
28            this.object.translate([0.0, this.height, 0.0]);
29        }
30    }
31}

这两个示例是等效的,并将导致相同的结果。

您会注意到第 5 行的区别:

1WL.onXRSessionStart.push(this.onXRSessionStart.bind(this));

与之比较

1this.engine.onXRSessionStart.add(this.onXRSessionStart.bind(this));

因为我们现在迁移到了npm 依赖和标准打包,已经不再需要全局 WL 变量。

拥有一个全局暴露的引擎有两个限制:

  • 难以共享组件
  • 无法运行多个引擎实例

尽管第二个要点并不是一个常见的用例,但我们不想在可扩展性方面限制任何用户。

对象变换 

新API基于Wonderland Engine中常用的模式:

1getValue(out) { ... }
2setValue(v) { ... }

变换的平移旋转、和缩放组件现在也遵循这个模式:

1const translation = this.object.getTranslationLocal();
2this.object.setTranslationLocal([1, 2, 3]);
3
4const rot = this.object.getRotationLocal();
5this.object.setRotationLocal([1, 0, 0, 1]);
6
7const scaling = this.object.getScalingLocal();
8this.object.setScalingLocal([2, 2, 2]);

如 API 的其他部分一样,使用空 out 参数的getter会导致输出数组的创建。请注意,尽可能重用数组(出于性能原因)始终是更好的选择。

除了能够读取和写入对象的本地空间变换,您还可以直接操作世界空间:

1const translation = this.object.getTranslationWorld();
2this.object.setTranslationWorld([1, 2, 3]);
3
4const rot = this.object.getRotationWorld();
5this.object.setRotationWorld([1, 0, 0, 1]);
6
7const scaling = this.object.getScalingWorld();
8this.object.setScalingWorld([2, 2, 2]);

有关更多信息,请查看 Object3D API 文档。

JavaScript 隔离 

这一部分对于所有用户来说都是一致的,无论您是否启用了 useInternalBundler

有很多方法可以在组件之间共享数据,如何选择合适的做法取决于应用程序的开发者。

以下是一些不依赖全局变量的共享数据的示例。

组件中的状态 

组件可被视为数据的包裹,可以通过其他组件访问。

因此,您可以创建组件来保存应用程序的状态。例如,如果您正在创建一个包含三种状态的游戏:

  • 进行中
  • 赢了
  • 输了

您可以创建一个具有以下形状的单例组件:

game-state.js

 1import {Component, Type} from '@wonderlandengine/api';
 2
 3export class GameState extends Component {
 4  static TypeName = 'game-state';
 5  static Properties = {
 6    state: {
 7      type: Type.Enum,
 8      values: ['running', 'won', 'lost'],
 9      default: 0
10    }
11  };
12}

然后可以将 GameState 组件添加到一个管理对象中。应由将改变游戏状态的组件引用此对象。

让我们创建一个组件,当玩家死亡时改变游戏状态:

player-health.js

 1import {Component, Type} from '@wonderlandengine/api';
 2
 3import {GameState} from './game-state.js';
 4
 5export class PlayerHealth extends Component {
 6  static TypeName = 'player-health';
 7  static Properties = {
 8    manager: {type: Type.Object},
 9    health: {type: Type.Float, default: 100.0}
10  };
11  update(dt) {
12    /* 玩家死亡,改变状态。 */
13    if(this.health <= 0.0) {
14      const gameState = this.manager.getComponent(GameState);
15      gameState.state = 2; // 我们将状态设置为 `lost`。
16    }
17  }
18}

这是在你的 pre-1.0.0 应用程序中替换全局变量的一种方式示例。

导出 

还可以通过importexport共享变量。然而,请记住整个打包中的对象将是相同的。

我们可以用导出重新审视上面的示例:

game-state.js

1export const GameState = {
2  state: 'running'
3};

player-health.js

 1import {Component, Type} from '@wonderlandengine/api';
 2
 3import {GameState} from './game-state.js';
 4
 5export class PlayerHealth extends Component {
 6  static TypeName = 'player-health';
 7  static Properties = {
 8    manager: {type: Type.Object},
 9    health: {type: Type.Float, default: 100.0}
10  };
11  update(dt) {
12    if(this.health <= 0.0) {
13      GameState.state = 'lost';
14    }
15  }
16}

这种解决方案有效,但并不是万无一失的。

让我们看一个这种解决方案失效的例子。假设这些代码在一个名为 gamestate 的库中。

  • 您的应用依赖于 gamestate 版本 1.0.0
  • 您的应用依赖于库 A
  • A 依赖于 gamestate 版本 2.0.0

您的应用将会附带 gamestate 库的两个副本,因为两个版本在兼容性上不同。

当库 A 更新 GameState 对象时,它实际上是在改变其自身的导出实例。这是因为两个版本不兼容,您的应用程序因此捆绑了库的两个不同实例。

结尾 

通过本指南,您现在已准备好将您的项目迁移到 Wonderland Engine 1.0.0

如果您遇到任何问题,请通过 Discord 服务器 联系社区。

Last Update: April 14, 2023

保持更新。