实现 H5 游戏引擎的物体编辑器

大家好,我是 AlphaLu。

这次给大家介绍笔者所在团队自研的 H5 游戏引擎里物体编辑器的实现方案,目前编辑器有两种 UI,分别是代码编辑器和表单编辑器,效果如下:

  • 代码编辑器
  • 表单编辑器

需求描述

ECSM 架构

当前游戏引擎采用的是 ECSM 架构,ECSM 即对应 Entity、Component、System、Model。

我们定义 Entity 为构成游戏世界的最小单位,并且 Entity 可以包含 Entity,实际上整个游戏世界就是一棵 Entity 树。

一个 Entity 可以根据需要添加不同的 Component,比如当它需要一个外观时,就为它添加一个 Render Component。

一个 Component 就是维护了一份属于 Entity 的状态数据,Component 会被对应的 System 处理,比如 Render Component 会被 Render System 处理。

一个 System 会在引擎的 tick 函数里处理一类 Component,比如 Render System 在处理 Render Component 时就会使用渲染引擎(如 pixi.js)为 Entity 渲染外观。

那么 Model 是什么?Model 用于描述 Entity 的具体内容,简单来说就是游戏引擎创建一个 Entity 时所依赖的一段 javascript 脚本,在这个脚本中,我们可以为 Entity 添加各种 Component。实际上,我们将 Model 抽象为一种资源,可被独立于引擎管理,可被高度复用,Model 与 Entity 的关系有点类似 Class 与实例对象的关系。

一个游戏世界就是一个场景,场景对应的数据模型为 Scene,Scene 继承自 Entity,所以 Scene 也有对应的 Model。我们构建一个游戏世界的过程就是在编写各种 Model 脚本,然后交给游戏引擎运行,引擎会以场景的 Model 为入口创建出整棵 Entity 树,也就创建出了一个游戏世界。

物体的含义

物体是什么呢?物体就是一类 Entity,如果映射到现实世界,就是包括生物与非生物,比如一只狗、一辆车、一个箱子等,不过这属于偏狭义的理解,在游戏引擎里,物体的含义其实是与 Entity 等价的。

物体编辑器的作用

我们的产品是小世界,这是一个基于 SolidJS 的纯前端应用,它专注于趣味教育领域,以优雅的方式允许用户编辑与运行游戏,可编辑性是我们的特色,它给予用户以趣味实验的感受。

可编辑性的一个重要体现就是支持方便地修改场景里物体的属性,比如修改一个箱子的尺寸、一个弹簧的弹性系数、一辆小车的控制逻辑等,这就需要一个物体编辑器,我们要利用它编辑目标物体的属性。

数据模型设计

定义物体属性

前面提到物体就是 Entity,但我们要修改的物体的属性并不是直接属于 Entity 的,或者说我们要修改的是物体的专有属性,我们将这些专有属性定义在 Entity 的 props 属性上。

ts 复制代码
class Entity {
    // ...
    props: Record<string, any> = {}
    // ...
}

描述物体属性

不同的物体具备不同的属性,在实现上物体之所以能够不同,是因为创建自不同的 Model,所以应当在 Model 里描述专有属性,这样在创建 Entity 时就能根据这个描述为 Entity 初始化专有属性。

这里笔者选择基于 async-validator 做扩展,并将这个描述命名为 propsSchema。

js 复制代码
// Model: box.js

export default {
    name: '箱子',
    propsSchema: {
        fields: {
          width: {
            type: 'number',
            defaultValue: 36,
          },
          height: {
            type: 'number',
            defaultValue: 18,
          },
        }
    },
    onCreate,
}

async function onCreate() {
    // 添加各种 Component
}

上面的这个 propsSchema 描述了"箱子"这个物体具有宽度、高度这两个专有属性,类型均为数字,并拥有初始值。

校验物体属性

当用户通过编辑器修改物体属性后,在应用新属性值到物体前需要校验一下,此时可以直接使用 async-validator 的 validate 相关 API。其实 propsSchema 就是一个规范,基于它可以做很多事情,比如初始化 props、序列化 props、扩展校验规则等。

编辑器 UI 设计

代码编辑器

代码编辑器允许用户以写代码的方式,直接编辑 props 对象,给予用户以更灵活的编程体验。

笔者这里选择了 monaco-editor 进行实现。

字符串化 props

monaco-editor 接收的是代码字符串,所以需要先将 props 对象转为字符串。不过先考虑一个问题,这个 props 字符串对应的应该是什么?是一个 js 对象,还是一段 js 代码?

js 复制代码
// 编辑 js 对象
{
    // 宽度
    width: 1,
}

// 编辑 js 代码
const props = {
    // 宽度
    width: 1,
}

笔者最终选择对应到一段 js 代码,主要是考虑更灵活的编程体验,可以根据需要编写规范的 js 代码:

js 复制代码
// 编辑 js 代码

function calcWidth = () => {
    // ...
}

const props = {
    // 宽度
    width: calcWidth(),
}

由于要支持在编辑器里规范地显示字段注释,所以实现一个类似 JSON.stringify 的函数,基本思路就是递归遍历 propsSchema 的各个字段描述,增加对字段注释的处理:

ts 复制代码
// 篇幅有限,下面代码的上下文不全,理解大致逻辑即可

function stringifyProps({
  data,
  isArrayItem = false,
  isFirstField = false,
}: {
  data: PropsValueCompiled;
  isArrayItem?: boolean;
  isFirstField?: boolean;
}): string {
  let ret = '';

  const {
    comment, value, descriptor: { type }, fieldName
  } = data;

  // 这里将字段注释添加到结果字符串中
  ret += reviseComment(comment, isFirstField || fieldName === 'root');

  if (['object', 'array'].includes(type || '')) {
    const wrapStart = type === 'object' ? '{\n' : '[';
    const wrapEnd = type === 'object' ? '}' : ']';
    const isArrayItemSub = type === 'object' ? false : true;
    ret += `${reviseFieldName(fieldName, isArrayItem)}${wrapStart}`;
    const keys = Object.keys(value);
    const ksl = keys.length;
    keys.forEach((k: string, i: number) => {
      const stringSub = stringifyProps({
        data: value[k],
        isArrayItem: isArrayItemSub,
        isFirstField: i === 0,
      });
      ret += `${stringSub}${i < ksl - 1 ? ',' : ''}`;
    });
    ret += wrapEnd;
  } else {
    let vt = value;
    switch (type) {
      case 'string': {
        vt = `"${vt}"`;
        break;
      }
      case 'enum': {
        vt = `"${vt}"`;
        break;
      }
      default:
    }
    ret += `${reviseFieldName(fieldName, isArrayItem)}${vt}`;
  }

  return ret;
}

function reviseComment(comment: string, isFirstField: boolean = false): string {
  if (comment) {
    comment = `\n${isFirstField ? '' : '\n'}${comment}\n`;
  }
  return comment;
}

function reviseFieldName(
  name: string, isArrayItem: boolean = false
): string {
  return `${(name === 'root' || isArrayItem) ? '' : `${name}:`}`;
}

在得到带有注释的 props 字符串后,还需要使用 prettier/standalone 对结果做进一步规范化:

ts 复制代码
// 篇幅有限,下面代码的上下文不全,理解大致逻辑即可

import * as prettier from 'prettier/standalone';
import prettierPluginBabel from 'prettier/plugins/babel';
import prettierPluginEstree from 'prettier/plugins/estree';

const propsFormated = await prettier.format(`const props = ${propsString}`, {
    parser: 'babel',
    plugins: [prettierPluginBabel, prettierPluginEstree],
});

反字符串化 props

当用户点击保存后,我们需要将 monaco-editor 的输出,也就是 props 字符串转为一个 props 对象,这个过程分两步:首先利用 jshint 对代码字符串做静态扫描,然后再通过 Function 运行代码字符串得到 props 对象。

ts 复制代码
// 篇幅有限,下面代码的上下文不全,理解大致逻辑即可

import { JSHINT, LintError } from 'jshint';

function parseCodeString(source: string): {
  value: PropsValue | null,
  error: PropsError | null
} {
  let value: PropsValue | null = null;
  let error: PropsError | null = null;

  const code = `${source}return props;`;
  const errors = staticCheck(`function f(){${code}}`);
  if (errors.length) {
    error = {
      type: 'compiletime',
      data: errors,
    };
  } else {
    try {
      value = Function(code)();
    } catch (e) {
      console.log('runtime error', e);
      error = {
        type: 'runtime',
        data: e,
      };
    }
  }

  return {
    value,
    error,
  }
}

function staticCheck(source: string): LintError[] {
  let errors: LintError[] = [];
  try {
    const options = {
      esversion: 6,
      // undef: true,
    };
    const predef = {};
    JSHINT(source, options, predef);
    errors = JSHINT.errors.slice(0);
    // console.log('compiletime error', errors);
  } catch (e) {
    console.log(e);
  }
  return errors;
}

表单编辑器

表单编辑器允许用户通过填写表单的方式编辑 props 对象,能提供更为友好、明确的编辑交互。

关于具体的实现方案,我在 前端需要面向对象编程吗?form-cross-view,跨视图动态表单生成框架? 这两篇文章里详细地介绍了,基本思路是基于 propsSchema 生成动态表单,另外对 method 字段的编辑方式做了比较有意思的尝试。

method 积木编程

前面提到,小世界专注于趣味教育领域,它面向的很大一部分用户是低龄学生,写代码对他们来说比较难,因此我们尝试提供一种更友好的逻辑编写方式,也就是积木编程。

笔者这里选择了 Google 的 blockly,按照官方文档一步步地完成接入即可。

总结

本文介绍了 H5 游戏引擎里物体编辑器的实现方案,首先说明物体编辑器的含义与作用,然后讲解物体属性的数据模型设计,最后阐述代码编辑器与表单编辑器的实现方案。

相关推荐
dr李四维9 分钟前
iOS构建版本以及Hbuilder打iOS的ipa包全流程
前端·笔记·ios·产品运营·产品经理·xcode
雯0609~30 分钟前
网页F12:缓存的使用(设值、取值、删除)
前端·缓存
℘团子এ33 分钟前
vue3中如何上传文件到腾讯云的桶(cosbrowser)
前端·javascript·腾讯云
学习前端的小z39 分钟前
【前端】深入理解 JavaScript 逻辑运算符的优先级与短路求值机制
开发语言·前端·javascript
彭世瑜1 小时前
ts: TypeScript跳过检查/忽略类型检查
前端·javascript·typescript
FØund4041 小时前
antd form.setFieldsValue问题总结
前端·react.js·typescript·html
Backstroke fish1 小时前
Token刷新机制
前端·javascript·vue.js·typescript·vue
小五Five1 小时前
TypeScript项目中Axios的封装
开发语言·前端·javascript
小曲程序1 小时前
vue3 封装request请求
java·前端·typescript·vue
临枫5411 小时前
Nuxt3封装网络请求 useFetch & $fetch
前端·javascript·vue.js·typescript