Skip to content
云风 edited this page Sep 2, 2024 · 6 revisions

游戏界面

除了非常简单的游戏,界面是必不可少的。这里谈及的界面 (ui) 指的是面向玩家的交互界面,而不是游戏开发过程中,面向开发者的调试界面,或是游戏开发中制作的开发工具用到的界面。Ant 提供了两套完全不同的界面方案。其中,用于调试和工具的界面基于立即模式的 imgui ,在这篇文章中并未涉及;而面向游戏玩家的游戏界面使用的是基于保留模式的 rmlui ,它更接近 web 技术,本文围绕它展开。

关于两种 UI 模式的讨论,可以参考 这篇 blog

想尝试 Ant 引擎,玩一下游戏界面是一个很好的开始。因为,编写 Ant 中的游戏界面,就像是在开发一个 web 网页。只需要编写一些类似 html 的文本,和一些 css 代码,再加上一点 Lua 脚本就可以运行起来。开发者不需要制作 Asset ,可以很快的看到工作成果。如果再加上一些图片,甚至能编写一些 2D 小游戏。

RmlUI

有很多游戏基于 web 技术开发游戏界面,在 CppCon2018 上,在 OOP Is Dead, Long Live Data-oriented Design 演讲中介绍了著名游戏 PUBG 使用了基于 web 技术的游戏界面。

在游戏引擎中嵌入 Webkit 这样的第三方模块实在是太重了,而且它是为浏览器设计的,并没有为游戏的应用场景做优化。上面提到的演讲中,也专门讲述了将传统的 web 技术应用到游戏界面中,需要做大量的优化工作。

我们选择了更为轻量的 RmlUI ,它的前身是 libRocket ,在此基础上做了大量的改进。Ant 在使用第三方库时,我们倾向于保持和第三方库同步开发,直接使用第三方库的最新分支。如有自己的想法,优先和第三方库的开发者沟通,尽可能的达成一致,将我们的想法融入到第三方库中。但 RmlUI 是一个例外。我们在集成 RmlUI 的过程中,发现了大量的问题,最终我们只能开启自己的分支。问题主要在两个方面:

第一,我们在接入 Lua 时,和原作者的想法有很大的差异。RmlUI 的原版 Lua binding 质量较差,原作者对 Lua 的理解不够。我们曾经试图帮助 RmlUI 重写 Lua binding ,但最终发现需要修改大量的 API 设计,这会破坏 RmlUI 的兼容性。

第二,原版 RmlUI 在处理复杂界面时有一些 bug 难以根除。有部分 bug 属于原始设计上的,如果不把大块代码重写,只在已有的代码上修复这些 bug 超出了我们的能力。

第三,原版 RmlUI 有许多功能我们用不上,却带来了代码的复杂性,难以维护。我们希望砍掉这些用不上的特性,让代码更简洁。

第四,原版 RmlUI 的主循环在 C++ 代码中,基于大量的 callback 函数工作。这使得它在一个 Lua 为主框架的引擎中使用及其困难,尤其是和 ltask 对接时,造成了很多不必要的限制。

最终,Ant 的游戏界面模块除了名字还保留为 RmlUI ,实质上演化成了和原版 RmlUI 几乎完全不同 的样子。

在分叉原版 RmlUI 后,Ant 改变了这些东西:

第一,重新设计了 Lua 脚本和相应的接口。

第二,css 排版,使用了 Yoga 以支持更严格的 css 语法,去掉了 RmlUI 自行实现的部分。注:在分叉时,RmlUI 的排版部分相当简陋,现在的官方版本要完善很多。注:Yoga 支持 flex 排版,不支持 grid 。

第三,去掉了 RmlUI 中大量(和 css 无关)的独有特性以及内置控件。

第四,把 RmlUI 变成了一个纯粹的 Lua 库,而不是一个以 C/C++ 为主干的框架。

第五,做了相当彻底的性能优化,大部分代码都重写了。

开启游戏界面

/test/rmlui 是一个只有游戏界面的示例。在启动脚本 main.lua 中:

import_package "ant.window".start {
    feature = {
        "ant.test.rmlui",
        "ant.rmlui",
        "ant.pipeline",
    }
}

引入了三个 ECS 特性,第一个 ant.test.rmlui 是这个示例的自定义 feature ;第二个 ant.rmlui 是提供给 游戏世界 使用的界面系统的接口;第三个 ant.pipeline 是必须引入的 feature ,定义了 ecs 的执行流程。

注:游戏界面模块工作在 ltask 的一个独立服务中,和游戏的主服务是隔离的,它不是 ecs 的一部分。无论你使用还是不使用游戏界面模块,引擎都会启动它。上面引入的 ant.rmlui 这个 feature 只是和这个界面模块对接的接口,并非模块本身。这套接口会通过 RPC 让游戏主服务和游戏界面服务协同工作。

ant.test.rmlui 只定义了 init 这一个 system ,代码如下:

local ecs = ...

local iRmlUi = ecs.require "ant.rmlui|rmlui_system"
local font = import_package "ant.font"

local m = ecs.system "init_system"

font.import "/pkg/ant.resources.binary/font/Alibaba-PuHuiTi-Regular.ttf"

function m:init()
    iRmlUi.open "/pkg/ant.test.rmlui/start.html"
    iRmlUi.onMessage("click", function (msg)
        print(msg)
    end)
end

从第一行 local ecs = ... 可知,这是一个 ECS 模块,准确说,它实现了 init_system 。注:init_system 的定义可以在 /pkg/ant.test.rmlui/package.ecs 找到。

font.import "/pkg/ant.resources.binary/font/Alibaba-PuHuiTi-Regular.ttf"

我们需要为界面导入至少一个字体。在这个示例中,导入了阿里巴巴普惠体。这是一个开源字体,你也可以为游戏选择其它字体。字体模块不是界面模块的一部分,它会被界面模块和图形模块共享。

在初始化 (init) 这个 stage 中,我们开启了一个用 .html 描述的界面。

iRmlUi.open "/pkg/ant.test.rmlui/start.html"

这一行用 RPC 通知游戏界面服务打开一个 html 页面,实际的界面运作以及界面的渲染过程,都在那个服务中进行。

我们需要注册回调函数,接收游戏界面服务回传过来的信息:

iRmlUi.onMessage("click", function (msg)
  print(msg)
end)

在这个示例中,仅仅只是把 click 消息附带的信息输出到控制台上。

实现游戏界面

游戏界面的实现都放在一个个 .html 文件中。这个 html 文件非常类似传统的网页。里面包含有 css 代码描述界面的排版格式,有 html 代码描述界面的逻辑结构,有 script 代码实现界面交互。和传统的网页不同,我们不使用 javascript 做内嵌脚本,而是使用 Lua ,和引擎的其它部分保持一致。

对于传统的浏览器,每个页面都有完全独立的环境,像 Chrome 甚至为每个页面分配一个独立进程。而对于 Ant 的游戏界面,所有的页面(一个 .html 就是一个页面)共享一个 Lua 虚拟机。每个页面有一个简单的 Lua 沙盒,隔离了不同页面的全局变量区。但开发者可以使用 requireimport_package 共享状态。即:开发者在不同的页面的 script 代码中,通过 import_package 导入同名的 Package,它是游戏界面服务中的同一个 Lua 虚拟机中的同一个包。

页面之间直接用包中的 api 交换数据,但如果需要和游戏主服务交换数据,则需要通过 RPC 的接口。嵌入页面的 script 中,默认导入了 window 这个模块,window.callMessage() 可以对游戏主服务发起一个 RPC 请求并等待回应。这个 RPC 请求不涉及网络,甚至不跨进程,所以非常轻量。也可以使用 window.sendMessage() 单向推送一条消息。

开发者也可以直接引入 require "ltask" ,使用 ltask 的 api 直接和其它服务通讯。

原本 RmlUI 实现了一个叫做 DataModel 的功能,用它可以简化页面开发。可以这样理解这个东西:传统的 web 开发中,我们不会直接使用原始的 javascript api 来开发页面。开发者会使用如 react vue 之类的前端框架,它们改进了浏览器原始功能的不足。而作为一个内嵌的游戏界面模块,我们没有必要遵循浏览器的标准,可以直接对页面语言做一些扩展。这样更轻量高效。

在 Ant 的游戏界面中,我们去掉了原版中的 DataModel ,重新实现了一套类似的东西。现在的界面交互代码看起来是这样的:

<body>
	<div>
		<button id = "new" data-event-click="new()">{{label}}</button>
		<button data-event-click="load()">载入游戏</button>
		<button data-event-click="setting()">设置</button>
		<button data-if="show">隐藏菜单</button>
		<button data-event-click="exit()">退出</button>
	</div>
</body>

data-event-clickdata-if 就是我们扩展的语法。data-event-click 表示点击这个 button 时,调用 script 的 Model 对象中某个函数。data-if 表示这个button 的可见性由 script 的 Model 对象中的某个布尔变量决定。{{label}} 引用了 Model 中的一个字符串。

还有许多扩展语法不在这篇文章中一一介绍。

上面这段 html 代码对应的 script 是这样的:

<script type="text/x-lua" >
	local model = window.createModel {
		show = false,
		label = "新游戏"
	}
	function model.new(event)
		window.callMessage("click", "new")
	end
	function model.load(event)
		window.callMessage("click", "load")
		model.label = "继续游戏"
	end
	function model.setting(event)
		window.callMessage("click", "setting")
		model.show = not model.show
	end
	function model.exit()
		window.callMessage("click", "exit")
	end
</script>

它为当前页面创建了一个数据模型 (model) ,定义了 show 和 label 两个变量,以及 new, load, setting, exit 等一系列函数。

至于页面的版面信息,和 web 页面一样,用 css 描述:

<style>
	body {
		font: 300% "阿里巴巴普惠体";
		background-color: gray;
	}
	div {
		margin-left: 60%;
		margin-right: 10%;
		margin-top: 10%;
		margin-bottom: 20%;
		gap: 20px;
	}
	button {
		padding: 0 20px;
		text-align: center;
		text-decoration: none;
		border-radius: 10px;
		border: 1px white;
		background-color: black;
		background-size: contain;
		color: white;
		transform: scale(1.0);
	}
	button:active {
		transform: scale(1.1);
	}
</style>

以上的全部,包括 html css 和 script 我们全部写在了同一个文件 /pkg/ant.test.rmlui/start.html 中。对于更复杂的应用场合,推荐把 css 和 script 写在独立文件里。

<style path = "xxx.css"/>

这样可以引用一个外部的 .css 文件。

<script path = "xxx.lua"/>

这样可以引用一个外部的 .lua 文件。

DOM API

在 script 中,可以用 document 这个变量访问该页面的根元素(element)。事件回调函数的参数中 ev.current 可以取出触发事件的元素对象。

元素对象的接口参考见 https://mikke89.github.io/RmlUiDoc/pages/cpp_manual/elements.html ,它和 web 开发中的对应接口非常类似。例如,你可以用 document.getElementById(name) 获取 id 为 name 的元素对象,等等。

和游戏一起运作

上面已经介绍过,游戏界面和游戏场景分处不同的服务中。它们工作在不同的线程的不同 Lua 虚拟机上。所以,不能用任何共享状态的方式协作。

标准的做法是在负责游戏场景渲染的游戏主服务中使用 onMessage 定义消息回调函数,接收游戏界面发送给游戏场景的信息。

local iRmlUi = ecs.require "ant.rmlui|rmlui_system"
iRmlUi.onMessage(message_name, callback_function)

在游戏界面的 script 中,使用 window.callMessage()window.sendMessage() 向游戏场景推送消息。

如果需要从游戏场景向界面发送消息,可以反过来在界面的 script 中使用 window.onMessage() ,在场景中使用 iRmlUi.callMessage()iRmlUi.sendMessage()

另外,还可以直接在游戏界面的 script 中导入 ltask 模块,直接和其它服务通讯。游戏主服务并没有特别的限制,可以用它的名字 ant.window|world 查询到服务地址,然后就可以通过 ltask 的 api 通讯了。

我们推荐,尽量把游戏界面内部的交互逻辑写在页面的 script 中。如果你只是需要两个页面之间交互,例如,点击一个页面上的按钮,打开另一个页面。不必把消息发送回游戏主服务,再由游戏主服务驱动游戏界面,直接在游戏界面服务中即可完成。

只有一定需要和游戏主服务交换数据的场合,再使用 RPC 接口。

消息过滤

RmlUI 会过滤掉 touch 和 gesture 消息,但不会处理 mouse 和 key 消息。也就是说,如果你的游戏直接处理 mouse 消息,即使在 UI 层处理了点击事件 (gesture click) ,游戏依然会收到鼠标按钮消息。所以,在有 UI 的场合,尽可能使用 gesture 而不要直接使用鼠标消息。

如果你想让 UI 透传消息,需要在对应的控件上标注 pointer-events: none

引用图片

游戏界面默认只能引用 VFS 路径中的图片。这些图片由 Asset 管理,和游戏引擎的其它部分针对贴图管理的处理方式是一样的。你在游戏中使用了一张贴图,如果这张贴图被游戏界面引用,它在内存中是同一份实例。

在游戏界面中展示一张图片时,如果该图片是一个 .png 文件,它同样会经过资产管理模块,在开发期编译为压缩格式的贴图数据。游戏界面在运行期默认不能直接使用原生的 png / jpg 等通用格式的图片文件。假如你的游戏在运行时通过网络下载了一些图片文件到本地文件系统中,恐怕无法使用。

但是,Ant 的游戏界面模块可以动态扩展不同的图片协议。在引用图片是,写上 "protocol://" 前缀,就可以把该图片交由不同的服务处理。我们可以扩展一个 http:// 协议,就能从网络获取图片文件,并在运行时转换为引擎认识的贴图。这个特性目前没有实现,但很容易扩展。

Ant 已经扩展了两种不同的图片协议:

一,是九宫格式的图片,可用于背景图样。只需要创作一个九宫格贴图,游戏界面模块会在使用时平铺拉伸,适应不同的控件尺寸。

二,可以让 3d 渲染器在运行时渲染一张图片。这可以方便开发者在游戏界面中嵌入 3D 模型甚至游戏场景。

Clone this wiki locally