JS 游戏引擎 - 添加 DOM UI

本文给大家介绍如何为 JS 游戏引擎添加 DOM UI,下图里的手机、日志面板、控制面板均用 DOM 实现,而非使用 Canvas 绘制:

在这里插入图片描述

需求描述

通常我们整个游戏场景的核心视图是基于 Canvas 绘制的,我们将那些不是用 Canvas 绘制的、直接由 DOM 构成的视图定义为 DOM UI。

DOM UI 是游戏场景 UI 层的一种实现方式,游戏场景 UI 层用于容纳不属于游戏世界的元素,比如一个箱子是属于游戏世界的,但一份游戏说明并不属于游戏世界,或者说游戏世界中的角色“看”不到这份游戏说明,因为它是设计给用户看的。

我们当然可以使用 Canvas 实现游戏场景的 UI 层,但使用 DOM 会更好,主要考虑两点:

  1. 开发效率更高:一方面写 HTML/CSS/JS 比调用 Canvas 绘制 API 要简单许多;另一方面,我们还能使用丰富的前端框架进一步提高写 HTML/CSS/JS 的效率。可以想象,要开发上面效果图里的手机,使用 Canvas 绘制 API 会相当复杂。
  2. 实现效果更好:一方面是在处理用户输入事件时,DOM 的事件机制性能可能会更好,如果使用 Canvas,则需要通过监听 Canvas 的事件再查找目标元素,这个查找过程可能会使用到遍历,而遍历的耗时正相关于游戏场景内的元素数量;另一方面在缩放页面时,DOM 视图不会变模糊,而 Canvas 是基于像素的,在放大页面的情况下,画布内容会变模糊,为了保证其不变模糊,往往需要调整画布的分辨率,但这也增加了复杂度。

那么该如何让当前的 JS 游戏引擎支持添加上面提到的这种 DOM UI 呢?

需求分析

很自然地,我们需要解决两个关键问题:

  1. 如何使用 DOM UI?具体点,它是一种资源吗?和 Model (ECSM 架构)有什么关系?
  2. 如何开发 DOM UI?具体点,要用原生的 HTML/CSS/JS 还是基于框架?

方案设计

定义形式

DOM UI 的组成就是 HTML/CSS/JS,所以它的实际存在形式就是 HTML/CSS/JS,可以是内容字符串,也可以是文件:

// index.template
<div id='phone' class='phone'>phone</div>

// index.css
.phone { color: blue }

// index.js
const phone = document.getElementById('phone');

我们把 DOM UI 当做一种静态资源,就像是一张图片,可以为 Model 所引用:

public/
|--models/
    |--Customer/
        |--index.js
        |--avatar.png
        |--dom/ // ===> 这个目录下用于存储 DOM UI 资源
            |--Phone/ // ===> 这里定义了一个手机组件
                |--index.template
                |--index.css
                |--index.js

引用方式

在 Model 里该如何引用 DOM UI 呢?我们基于 ECSM 架构,定义了一种 DOM Component,用来存储 DOM UI 相关的数据:

// public/models/Customer/index.js

export default { 
    name: '消费者',
    propsSchema: { },
    onCreate,
}

async function onCreate() {
    // ...
    
    this.addComp('DOM').setData({
        template: this.resolveUrl('./dom/Phone/index.template'),
        css: this.resolveUrl('./dom/Phone/index.css'),
        js: this.resolveUrl('./dom/Phone/index.js'),
    });
}

解析方式

DOM Component 会被交给 Render System 处理,Render System 会去加载对应的文件,得到对应的内容字符串。

处理 template

Render System 会为当前的 DOM Component 分配一个 div 标签,然后设置其内容为对应的 template 内容字符串,这个 div 标签会被添加到一个统一的 DOM UI 容器。

这个统一的 DOM UI 容器就是游戏场景的 UI 层,它的尺寸和 Canvas 一样,并且覆盖在 Canvas 上面。

处理 css

Render System 会为当前的 DOM Component 分配一个 style 标签,然后设置其内容为对应的 css 字符串,这个 style 标签会被添加到 head 标签里。

另外一种做法是 Render System 会为当前的 DOM Component 分配一个 link 标签,然后设置其 href 为对应的 css url,这个 link 标签会被添加到 head 标签里。

不过该如何解决不同 DOM Component 之间的 css 样式冲突问题呢?

处理 js

Render System 会使用 eval 执行对应的 js 字符串。

通常我们会在这段 js 代码里实现 DOM UI 与游戏场景的通信逻辑,这里面涉及到 dom 事件的绑定,不过我们不一定能顺利地找到目标 dom,比如如果不同的 DOM Component 的 template 存在相同 id 的元素,此时使用 document.getElementById 获得的结果将是不确定的。那么在这种情况下我们该如何找到目标 dom 以完成事件绑定呢?

使用框架

在面对一些复杂视图的需求时,使用原生的 HTML/CSS/JS 开发,工作量也会很大,所以我们应该支持使用框架来开发 DOM UI,这里我们选择了 SolidJS 技术栈,当然也可以选择其它的框架技术栈。

我们将一个 DOM UI 当做一个小型的前端应用工程来开发:

public/
|--models/
    |--Customer/
        |--index.js
        |--avatar.png
        |--dom/ // ===> 这个目录下用于存储 DOM UI 资源
            |--Phone/ // ===> 这里定义了一个手机组件
                |--dist/ // ===> 这里存放打包好的文件
                    |--index.js
                    |--index.css
                |--node_modules/
                |--index.jsx
                |--index.scss
                |--vite.config.js
                |--package.json
            |--dist
             

当完成开发,我们使用 vite 打包项目,得到最终的 index.js 和 index.css,然后为 DOM Component 所引用:

// public/models/Customer/index.js

export default { 
    name: '消费者',
    propsSchema: { },
    onCreate,
}

async function onCreate() {
    // ...
    
    this.addComp('DOM').setData({
        url: this.resolveUrl(`./dom/Phone`),
    });
}

Render System 处理 index.css 的逻辑和使用框架前的一样,不同的是处理 index.js,其实我们在 DOM UI 的 jsx 源码文件里导出的是一个 SolidJS 组件:

// public/models/Customer/dom/Phone/index.jsx

export default function App(props) {
    // ...
}

我们可以通过浏览器的 import 函数请求 index.js,直接得到一个符合 ES6 模块规范的 module,然后拿到这个 App 组件,最后使用 SolidJS 的 render 函数渲染 App 就行了:

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

const module = await import(`${jsUrl}`);
const App = module.default;

director.shell?.solidjsWeb.render(
  App,
  div
);

现在我们再来看看上面解析方式里遗留的两个问题:

  1. 如何解决不同 DOM Component 之间的 css 样式冲突问题?我们可以直接使用 css module 方案,这样打包得到 index.css 里的所有样式选择器就能以组件的颗粒度被区分。
  2. 如何顺利找到目标 dom 以完成事件绑定?我们可以直接在 jsx 上面绑定事件,另外我们可以使用框架提供的 ref 机制,从而避免使用 document.getElementById。

通信设计

通常业务数据是维护在游戏场景里的,而 DOM UI 只是展示这些业务数据,并且响应用户输入事件来通知游戏场景修改这些业务数据,显然我们需要考虑两个方向的通信:

  1. 数据 => 视图:游戏场景修改业务数据,通知 DOM UI 变化;
  2. 视图 => 数据:DOM UI 监听到用户输入,通知游戏场景修改业务数据。

数据 => 视图

我们会通过框架组件的 props 机制给 DOM UI 提供初始的业务数据和一个视图上下文对象 viewContext,这个 viewContext 为游戏场景所持有,DOM UI 内部通过 props 拿到 viewContext,然后可以在 viewContext 上面挂载更新视图的方法:

// public/models/Customer/dom/Phone/index.jsx

import { createSignal } from 'solid-js';

export default function App(props) {
    // ...
    
    const [name, setName] = createSignal(props.name);
    
    props.viewContext = {
        setName, // 挂载更新 name 的方法
    }
    
    return (
        <>{name()}</>
    )
}

这样游戏场景就能通过 viewContext 访问到更新视图的方法:

// public/models/Customer/index.js

export default { 
    name: '消费者',
    propsSchema: { },
    onCreate,
}

async function onCreate() {
    // ...
    
    this.addComp('DOM').setData({
        url: this.resolveUrl(`./dom/Phone`),
    });
    
    this.viewContext.setName('xxx'); // 这里就能更新 DOM UI 视图
}

视图 => 数据

同样地,我们通过框架组件的 props 机制给 DOM UI 提供方法,然后在 DOM UI 监听的事件回调函数里调用对应的方法即可,在此不赘述。

当然我们也可以使用基于发布订阅机制的事件模型来设计通信方案,在此不展开。

适配画布

我们的游戏场景支持缩放,它会有个基准比例,基准比例对应的是场景缩放倍数为 1 时的样子。在首次渲染场景时,引擎会根据画布的大小与场景的大小计算出基准比例,而画布的大小受限于页面的大小,所以后续每次 resize 页面时,都需要重新计算基准比例。这样做的好处就是在不同分辨率的屏幕下,画布呈现的游戏场景内容都是一样的,但是 DOM UI 的尺寸可不会自动适应不同分辨率的屏幕

前面提到,DOM UI 属于游戏场景的 UI 层,那么我们希望 DOM UI 的布局和大小对游戏场景来说是固定的,也就意味着当游戏场景为了适配不同分辨率的屏幕而缩放时,DOM UI 也应当进行等比例的缩放,我们称 DOM UI 应当适配画布

做法也简单,前面提到游戏场景的 UI 层其实就是一个“吸附”在画布上面的 div,它容纳了所有的 DOM UI,它的大小与画布保持一致,那么我们只需要通过设置该 div 的 scale 样式属性来保持这种一致性就行,具体就是把它的 width 和 height 设置为游戏场景的 width 和 height,然后把它的 scale 设置为基准比例即可。

总结

本文介绍了如何为 JS 游戏引擎添加 DOM UI,先讲述 DOM UI 的含义以及作用,然后提出实现 DOM UI 的关键问题,接着从定义形式、引用方式、解析方式、使用框架、通信设计、适配画布几个方面,详细地阐述整个 DOM UI 的方案设计,欢迎大家交流意见。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值