« April 2022 | Main | June 2022 »

May 19, 2022

RogueLike 原型开发工具

我很喜欢 RogueLike 游戏,我是说字面意义上的像 Rogue 那样的游戏 。在这种游戏中,画面是最不重要的部分,只要能清晰表达出游戏需要的交互意义就够了。

我对用字符拼凑出来的游戏界面有种特别的爱好,小时候自制游戏就是从 text mode 开始的。 在今天,如果只是想验证一下某个游戏的原型,一个 Rogue 风,text mode 的交互界面,可能还是最省事的。因为你不必刻意的去准备美术素材,考虑这些素材如何和代码协同工作,制定繁杂的工作流。为游戏创作一点有趣的 ascii art 不仅不用太长的时间,可能还是写代码过程中的一点有趣的调剂。

前段时间在做项目中一个试验性模块时,我尝试做了一个简单的 Lua 库 帮助我用 text mode 搭建交互。最近又碰到一个更复杂的需求,评估了一下,基于 SDL2 做一个更完善一点的 RogueLike 库或许更一劳永逸。

于是上周我花了两天时间做了这么一个东西:https://github.com/cloudwu/rogue

它的核心是一个 text mode 的 sprite 。支持用 IBM Dos 下的 CP437 字符集以及 Unicode 中简体中文的子集拼凑 Sprite ,当然接口采用现代 UTF-8 标准。可以支持 256 个图层,以及 RGB 描述的色彩(而不是传统意义的 text mode 下16 色调色盘)。

汉字的支持用了一点小技巧,在内部的虚拟 text frame buffer 中的每个 slot ,都保存了完整的 Unicode 码点,以及它可能属于汉字的左半边还是右半边。这样彻底不会有过去 text mode 下半个汉字乱码的问题。

sprite 采用的是 retained mode ,即,只要创建出 sprite ,就一直在内部的链表上,每帧都会画出来。这点和很多传统的类似库不同,传统做法一般是 immediately mode ,也就是每帧你都需要 draw 特定的 sprite ,如果不调用 draw 就不会显示。

我曾考虑过某种混合模式,就是区分 map 和 sprite 。地图用 retained mode ,角色用 immediately mode 。但我觉得会增加接口复杂性。目前这样也暂时够用了。

在写这个玩具时,不时让人回忆起中学时在 Apple ][ 上做那些小游戏的时光。就是从那个时候起,我逐步悟到需要把程序里通用的需求提取出来做成共用的模块;游戏程序就是一个被设计出来的状态机;数据应该和程序分离;设计数据结构是程序设计中最重要的环节;应该为组织数据编写相应的工具……

真是一小段美好的开发经历。

May 07, 2022

游戏数据的展示

游戏的业务逻辑到画面呈现的过程和 GUI 系统在结构上有相似之处,又有一些不同点。

在软件设计时,我们通常倾向与把数据和展示分离。在 GUI 系统中,数据通常被称为 Model ,展示被称为 View 。典型的 MVC (及其衍生品 MVP/MVVM 等)模式就是建立在此基础上。业务逻辑修改 Model ,经过展示模块,把 Model 映射到 View 中呈现给用户。

传统的面向对象设计中,很多人倾向于按对象划分,每个对象有数据部分和展示部分。对于游戏,我不喜欢这种设计。我更倾向于把数据和展示完全分离,再用 id 把同一个对象关联其数据模块和展示模块中分离的实体。这是因为,游戏尤其是虚拟世界广阔的游戏,屏幕展示的仅仅是虚拟世界很小的一部分。在同一时间,大部分的数据都不必展示。甚至数据也未必存在于本地的内存中。

我倾向于把游戏软件切分为 gameplay 和 view 两个完全分离的模块,各自有独立的数据结构和设计。gameplay 应该可以完全独立于 view 运转,而让图形引擎关心且只关心 view 部分。在制作游戏软件时,如何解决好 gameplay 的信息如何在 view 模块中展示出来,就成了必须考虑的设计点。

设计数据如何呈现,必须考虑图形引擎的对外接口是怎样的。图形引擎的使用接口通常有两种设计:立即模式(Immediate Mode)或驻留模式(Retained Mode)。所谓立即模式,就是每帧都是从空白开始,把需要渲染的对象依次提交,最终得到画面;而驻留模式则由引擎保留一个待渲染的对象集,用户可以增加或减少需要渲染的对象,并可以修改已经加入渲染集的对象的状态。在图形底层 API 中,一般都只提供立即模式,而图形引擎则会进一步封装。在 2D 引擎中,两种模式的设计都很多见;但 3D 引擎由于 3D 渲染对象的复杂度大大超过 2D 图片,几乎都是提供的驻留模式接口:即,创建一个可显示对象,并可以之后销毁。在对象存活时,可以随时修改它的各种状态,包括并不限于,材质、位置、播放动画、挂接新的对象等。

对于场景很小的游戏,view 就是完整 gameplay 的体现。每个 gameplay 中的实体都在 view 中有一个对应品。在 gameplay 中的实体状态发生改变时,我们可以对应的设置 view 中对象的状态,正确表现出来。虽然对象并不总在镜头内,但渲染引擎通常会根据摄像机的位置和角度裁剪掉不必显示的对象。

如果场景很大,或有多个场景同时运转(只显示其中一个),一股脑的把 gameplay 中所有对象交给 view 模块就不明智了。我建议再设计一个叫做 viewport 的模块,它用来表达 gameplay 中的一个需要展示出来的区域。viewport 应该是一个保留有自身状态的对象,在每个渲染帧开始时,viewport 去 gameplay 中查询该区域中应被显示出来的对象,每个对象有唯一的 id 。viewport 应和自身保留的上一次查询的对象的状态向比较,得到这次查询和上次查询的差异。例如,这次查询如果和上次查询的位置相同,当前的位置就不用表达在查询结果中。这样,能更好的配合驻留模式的引擎接口。

而 view 模块中,也不是所有的可显示对象都会在 gameplay 中有对应品。我们应该标记出那些会受 gameplay 影响的对象,方便筛选出来。例如,有些用于和用户交互的可显示对象就可能在 gameplay 中并不存在。还有一些固定的作为背景的场景、渲染气氛用的特性等等。

在每一渲染帧,遍历出所有 gameplay 对应的渲染实体,再和前面的 viewport 查询结果集相比较,就可以知道这些对象需要做那些状态修改。以及,是否有需要增加的新对象,或是某些对象应该暂时隐藏掉。这样,viewport 也代替了图形引擎做了一次更高效的裁剪。在同一时间,viewport 往往不只一个。比如,游戏的主画面和小地图就是两个独立的 viewport 。它们所关心的范围不同,关心的信息也不同。小地图关心的范围更大,但只关心需展示的实体很少的信息。

对于 gameplay 信息不完全在本地内存中的场合,例如网络游戏,这个模式依然有效。在 MMORPG 的客户端中,gameplay 的对应模块其实是完整 gameplay 数据(存在于服务器上)的一个局部映射。通过网络包逐步的把部分 gameplay 数据同步下来。我们依然可以利用这样的模式把映射下来的部分数据再筛选展示。这种场合,viewport 模块更像是一个高效的裁剪器,裁剪掉不需要用于展示的数据。和图形引擎的裁剪模块不同,它拥有更多的和图形无关的信息。例如,你在封闭的房间内,屋外的对象就不需要在渲染引擎中更新和展示。你在二楼时,用于显示的 viewport 或许也不需要关心三楼的玩家;但同时可以有另一个用于发出环境声的 viewport 可以帮你播放楼上玩家的脚步声。显然,只是基于空间位置信息的图形渲染模块很难做得更好。

一个设计良好的 viewport 模块(从 gameplay 中提取出当前需要传入 view 的信息)可以帮助游戏数据和展示有效的分离。我建议在游戏软件设计的早期就应该重点考虑它的具体设计。