插件开发

一般通过脚本组件的方式就可以实现大部分逻辑,涉及引擎级别或者业务级别的通用能力,可以使用插件的形式进行开发,插件主要是由 Component 和 System 组成。

Eva.js 的渲染是基于 PixiJS 的,一般 Img/Sprite/Spine 等插件实际上是创建了 Pixi 的渲染对象,像 Stats/EvaX/Transition 等插件不依赖 Pixi。不管是哪种插件都是输出 Component 和 System 给引擎使用,但是开发方案有一些不同,接下来我会先讲解最简单的插件开发方法。

我们提供了一个插件模板,可以点击 Use this Template 直接使用模版进行开发,里面带了必要的脚手架。

基础

开发

读到这里,相信大家已经对 Eva.js 有所了解并且知道如何在项目中使用了,下面是一个插件的简单的使用方法。

  1. import { Demo, DemoSystem } from './tutorials/lib'
  2. const game = new Game({
  3. systems: [new DemoSystem()]
  4. })
  5. const go = new GameObject()
  6. go.addComponent(new Demo())
  7. game.scene.addChild(go)

我们可以看到,插件是由 Component 和 System 组成的,并且一个插件中不一定只包含一个 Component。

所以,开发插件需要实现暴露给用户使用的 Component 和 System。

插件运行逻辑

组件(Component)可以赋予游戏对象能力,我们将一些配置和属性记录在组件上。 系统(System)用来读取组件上面的数据,实现组件对应的能力。

当系统被添加到游戏实例上后,系统在它所需关心的组件在添加、移除、属性变化时,做一系列对应的操作,即可实现一些功能。

例如在 Img 插件中,当 Img 被添加到游戏对象上时,System 内会创建一个 Pixi 的 Sprite 对象,挂载到 GameObject 对应的 Pixi Container上,当 Img 组件的 resource 发生变化时,System 会去修改对应 Sprite 上面的 texture。

接下来,我会讲解如何设计一个组件,以及 System 是如何监听组件变化的。

构建与发布规范

开发实践

下面以 @eva/plugin-a11y 插件为例,对 Eva.js 插件开发做一个详细的介绍。

@eva/plugin-a11y 用于为游戏对象添加无障碍的能力。在 DOM 开发中,无障碍阅读器是可以阅读到 HTML 元素内容的,目前在 Canvas 里的绘制元素无法实现无障碍化的能力,@eva/plugin-a11y 插件通过定位游戏对象的位置,自动化地添加辅助 DOM,使得游戏对象能被无障碍阅读器聚焦,让游戏拥有无障碍功能。

首先设计 Component,既需要赋予游戏对象的能力。

使用案例

  1. import { A11y, A11ySystem } from '@eva/plugin-a11y'
  2. const game = new Game({
  3. systems: [new A11ySystem()]
  4. })
  5. const go = new GameObject()
  6. go.addComponent(new A11y({
  7. hint: '所需朗读的内容'
  8. }))
  9. game.scene.addChild(go)

Component 设计

  • 确定组件名称: A11y
  • 设计组件参数:
    • hint 需要朗读的内容
  1. import { Component } from '@eva/eva.js'
  2. export default class A11y extends Component {
  3. static componentName: string = 'A11y' // 这里是Component的名称,用于 System 监听变化
  4. /**
  5. * 无障碍标签朗读内容
  6. */
  7. public hint: string
  8. /**
  9. * 初始化方法,构造函数的参数会传递到这里
  10. */
  11. init(param = {hint: ''}) {
  12. const { hint } = param
  13. this.hint = hint
  14. }
  15. }

System 设计

  • 确定要监听的组件,以及需要监听哪些参数的变化
  • 确定系统名字
  • 根据组件变化实现逻辑

Step1 确定要监听的组件以及参数

  1. import { System, decorators } from '@eva/eva.js'
  2. @decorators.componentObserver({
  3. A11y: ['hint'] // 监听 A11y 组件的 hint 属性变化
  4. })
  5. class A11ySystem extends System {
  6. }

在上面的代码中,我们将需要监听变化的组件名称和监听属性传入 @decorators.componentObserver 中,以便创建监听。

如果只需要监听组件添加移除可以不填写具体的属性,例如

  1. @decorators.componentObserver({
  2. A11y: [] // 监听 A11y 组件的 hint 属性变化
  3. })

如果监听的属性不是直接挂载到组件对象上的,还有一级嵌套

例如监听组件 A 的 style 属性下的 size 属性

可以这样写:

  1. @decorators.componentObserver({
  2. A: [{
  3. prop: ['style', 'size']
  4. }]
  5. })

如果想要深度监听 style 属性,可以这样写

  1. @decorators.componentObserver({
  2. A: [{
  3. prop: ['style'],
  4. deep: true
  5. }]
  6. })

如果想监听多个组件变化,可以这样写

  1. @decorators.componentObserver({
  2. A: [{
  3. prop: ['style'],
  4. deep: true
  5. }]
  6. B: ['props']
  7. })

Step2 设置系统名字

给 System 设置名字

  1. import { System, decorators } from '@eva/eva.js'
  2. @decorators.componentObserver({
  3. A11y: ['hint'] // 监听 A11y 组件的 hint 属性变化
  4. })
  5. class A11ySystem extends System {
  6. static systemName = 'A11ySystem';
  7. }

Step3 根据组件变化实现逻辑

在此之前,我们做了一些监听配置,那么我们如何拿到对应的变化呢?

我们知道 System 有 update 生命周期,我们在生命周期中可以获取到当前帧 Component 的变化。

  1. import { System, decorators, ComponentChanged } from '@eva/eva.js'
  2. @decorators.componentObserver({
  3. A11y: ['hint'] // 监听 A11y 组件的 hint 属性变化
  4. })
  5. class A11ySystem extends System {
  6. static systemName = 'A11ySystem';
  7. private elemMap = new Map()
  8. update () {
  9. const changes: ComponentChanged[] = this.componentObserver.clear() // 获取当前帧所有需要监听的组件大变化,并且进行清理
  10. for (const changed of changes) {
  11. switch (changed.type) {
  12. case OBSERVER_TYPE.ADD:
  13. this.add(changed);
  14. break;
  15. case OBSERVER_TYPE.CHANGE:
  16. this.change(changed)
  17. break;
  18. case OBSERVER_TYPE.REMOVE:
  19. this.remove(changed);
  20. break;
  21. }
  22. }
  23. }
  24. add(changed) {
  25. if (changed.componentName === 'A11y') { // 如果有多个Component的话需要分开处理
  26. const component = changed.component as A11y
  27. const elem = document.createElement('div')
  28. elem.setAttribute('aria-label', component.hint);
  29. this.elemMap.set(component, elem)
  30. document.body.append(elem) // 添加到body上
  31. }
  32. }
  33. remove(changed) {
  34. if (changed.componentName === 'A11y') { // 如果有多个Component的话需要分开处理
  35. const component = changed.component as A11y
  36. const elem = this.elemMap.get(component)
  37. elem.remove() // 移除elem
  38. }
  39. }
  40. change(changed) {
  41. if (changed.componentName === 'A11y') { // 如果有多个Component的话需要分开处理
  42. if (changed.prop?.prop[0] === 'hint'){ //如果有多个监听属性需要分开处理
  43. const component = changed.component as A11y
  44. elem.setAttribute('aria-label', component.hint);
  45. }
  46. }
  47. }
  48. }

ComponentChanged 对应的类型是这样的,可以参考,不需要在代码里实现

  1. export interface PureObserverProp {
  2. deep: boolean;
  3. prop: string[];
  4. }
  5. export enum ObserverType {
  6. ADD = 'ADD',
  7. REMOVE = 'REMOVE',
  8. CHANGE = 'CHANGE',
  9. }
  10. export interface ComponentChanged {
  11. type: ObserverType;
  12. component: Component;
  13. componentName: string;
  14. prop?: PureObserverProp;
  15. gameObject?: GameObject;
  16. systemName?: string;
  17. }

现在我们把DOM创建好,并且放到了 body 上面,按照能力来讲,我们已经完成了具体的功能,因为屏幕阅读器已经可以阅读游戏中的元素了,但是看起来目前欠缺一些内容,例如:无法通过触发 DOM 点击事件来触发游戏里面的点击,DOM 的没有宽高和定位。

如果想实现这些功能,就要去在当前组件下拿到别的组件去实现功能了,如果想触发点击事件,需要判断 Event 组件是否安装,如果安装的话,可以根据 Event 上绑定的事件,触发对应的事件。如果想获取宽高位置的话,可以获取游戏对象的 Transform 组件

增加 Event 组件的监听,在上述 add remove 等方法里做对应操作即可。

  1. @decorators.componentObserver({
  2. A11y: ['hint'] // 监听 A11y 组件的 hint 属性变化
  3. Event: [] // Event 增加删除监听
  4. })
  5. class A11ySystem extends System {
  6. }

对于位置和宽高,可以在 A11y 组件被添加时拿到对应 GameObject 的 Transform,这里仅举个例子

  1. add(changed) {
  2. if (changed.componentName === 'A11y') { // 如果有多个Component的话需要分开处理
  3. const component = changed.component as A11y
  4. const elem = document.createElement('div')
  5. elem.setAttribute('aria-label', component.hint);
  6. this.elemMap.set(component, elem)
  7. document.body.append(elem) // 添加到body上
  8. const transform = changed.gameObject.transform
  9. elem.style.width = transform.size.width + 'px'
  10. elem.style.height = transform.size.width + 'px'
  11. elem.style.x = transform.position.x + 'px'
  12. elem.style.y = transform.position.y + 'px'
  13. }
  14. }

基于 PixiJS 的插件

以图片组件举例:

  1. import {
  2. GameObject,
  3. decorators,
  4. resource,
  5. ComponentChanged,
  6. RESOURCE_TYPE,
  7. OBSERVER_TYPE,
  8. } from '@eva/eva.js';
  9. import {
  10. RendererManager,
  11. ContainerManager,
  12. RendererSystem,
  13. Renderer,
  14. } from '@eva/plugin-renderer';
  15. @decorators.componentObserver({
  16. Img: [{prop: ['resource'], deep: false}],
  17. })
  18. export default class Img extends Renderer { // 基于 PixiJS 渲染的插件,我们的 System 需要继承于统一的一个 Renderer 类
  19. rendererSystem: RendererSystem;
  20. init() { // 在init中去获取 rendererSystem 以便后续添加 Pixi 对象,并且需要将当前系统注册到rendererManager中。
  21. this.rendererSystem = this.game.getSystem(RendererSystem) as RendererSystem;
  22. this.rendererSystem.rendererManager.register(this);
  23. }
  24. rendererUpdate(gameObject: GameObject) { // rendererUpdate 代替 Update 方法,因为update在 Renderer 类中已经实现
  25. const {width, height} = gameObject.transform.size;
  26. if (this.imgs[gameObject.id]) {
  27. this.imgs[gameObject.id].sprite.width = width;
  28. this.imgs[gameObject.id].sprite.height = height;
  29. }
  30. }
  31. async componentChanged(changed: ComponentChanged) { // 在 Renderer 类中实现了 update 方法,并且将 Img 对应的组件变化传递给 componentChanged
  32. if (changed.componentName === 'Img') {
  33. const component: ImgComponent = changed.component as ImgComponent;
  34. if (changed.type === OBSERVER_TYPE.ADD) {
  35. const sprite = new Sprite(null);
  36. resource.getResource(component.resource).then(({instance}) => {
  37. if (!instance) {
  38. console.error(
  39. `GameObject:${changed.gameObject.name}'s Img resource load error`,
  40. );
  41. }
  42. sprite.image = instance;
  43. });
  44. this.imgs[changed.gameObject.id] = sprite;
  45. this.containerManager
  46. .getContainer(changed.gameObject.id)
  47. .addChildAt(sprite.sprite, 0); // 将创建的 Pixi 渲染对象放进 GameObject 对应的 Pixi 容器中
  48. } else if (changed.type === OBSERVER_TYPE.CHANGE) {
  49. const {instance} = await resource.getResource(component.resource);
  50. if (!instance) {
  51. console.error(
  52. `GameObject:${changed.gameObject.name}'s Img resource load error`,
  53. );
  54. }
  55. this.imgs[changed.gameObject.id].image = instance;
  56. } else if (changed.type === OBSERVER_TYPE.REMOVE) {
  57. const sprite = this.imgs[changed.gameObject.id];
  58. this.containerManager
  59. .getContainer(changed.gameObject.id)
  60. .removeChild(sprite.sprite);
  61. delete this.imgs[changed.gameObject.id];
  62. }
  63. }
  64. }

生命周期

image.png

总结

通过 Component 和 System 的结合,我们可以实现各种各样的通用插件,在日常开发中,我们仅需要 CustomComponent 提供的能力开发游戏逻辑即可。