阅读视图

发现新文章,点击刷新页面。

有惊无险的一次网站系统升级

好消息是:这个 blog 终于是 UTF-8 编码了。前些年老有人问我能不能把 RSS 输出改成 UTF-8 的,很多 RSS 阅读器不支持 gbk ,这次终于改过来了。

事情源于昨天下午的一次脑抽,我把网站机器的操作系统升级了。上次升级还是十多年前,真的是太老旧了。结果升完级一看,php 被强制升到了 7 ,我自己写的一些 php 程序(主要是留言板)坏掉了。

这些个程序是我在 2004 年重构 2002 年的代码完成的;而 2002 年是从网上随便找来的代码基础上改的。我正儿八经学习 PHP 是在 1997 年,2000 年后就没怎么更新 PHP 的知识了。上次网站升级的时候,PHP 从 4 强制升到 5 ,就乱改了一通,勉强让程序可以运行(开了一些兼容模式)。这次再看代码,简直是惨不忍睹。所以我在本地装了个 PHP8 ,打开 PHP 官网,好好学习了一下手册。然后把代码取下来,重新建了个 git 仓库,正儿八经的改了一下。把留言的部分删了,只留下了浏览旧信息的部分,勉强让它继续跑起来。等什么时候有空了,再用 PHP 或 Lua 重新做一个。

Apache 的配置语法变了,一开始 PHP 跑不起来,折腾了一下配置文件就可以了。

最大的麻烦是 MySQL ,这次强制升到了 8 。之前好像是 4 版或更老的版本。我打开 blog 管理后台一看,全是乱码。心想坏了,编码出问题了。Blog 全是静态页面。只在修改时才从数据库读出内容生成一遍静态页面。所以外面看是正常的。我赶紧关掉了 mysql 服务器,以免(有人留言等修改行为)造成二次伤害。

Blog 是在 2005 年建的,数据采用的是 gbk 编码。其实那一年我已知道未来 UTF-8 一定是主流,但脑子里想的是手机流量费用 3 分钱 1 K 。选用 GBK 而不是 UTF 8 可以为自己和读者省钱。记得那年我和有道的负责人周枫闲聊汉字编码问题,他说 GBK 编码还是有意义的,他们当时爬虫爬来的中文数据储存就是用的 GBK ,这样可以节省 1/3 的储存成本。

其实,当年于我更好的方案应该是储存使用 utf-8 ,只在传输层用 GBK ,以后改起来也方便。可惜当年我自我折腾的能力远比不上现在,用了个别人开发的 blog 系统就懒得折腾了。在古旧得 Mysql 数据库中,是不储存文本编码类型的。基本上是你写什么数据编码就存什么。后来升级后,那些没有标注的编码字段就统一标注成了 latin1/latin1swedishci 。但实际我储存的是 gbk ,读出来自然就乱了。

一开始我觉得,这种问题肯定无数人解决过,google 一下就好。我把通讯编码改成 binary ,select 了几段文本,查看二进制表达,确认是 GBK 编码,数据没有(因为升级或后续操作)损坏。打包了一下数据库仓库目录,想着问题总能解决的吧。

我没有正儿八经的用 mysql 开发过,每次用到 mysql ,都是现学现卖。结果 google 了半天没找到解决方案,有点慌了。估计是像我这样跨越 10 年升级的用户太少了。在 mysql 官网上是这样写的

A special case occurs if you have old tables from before MySQL 4.1 where a nonbinary column contains values that actually are encoded in a character set different from the server's default character set. For example, an application might have stored sjis values in a column, even though MySQL's default character set was different. It is possible to convert the column to use the proper character set but an additional step is required. Suppose that the server's default character set was latin1 and col1 is defined as CHAR(50) but its contents are sjis values. The first step is to convert the column to a binary data type, which removes the existing character set information without performing any character conversion: ... The next step is to convert the column to a nonbinary data type with the proper character set:

简单说就是,先把文本标注成二进制格式,然后再转为你确定的编码。之后就可以正确转换到 UTF-8 了。

但我试了一下还是搞不定,只好在推特上求助。网友中数据库专家肯定比我这种临时抱佛脚翻手册的强多了。感谢热心网友提供了很多方案,甚至私信教我 mysql 。上面的方案我搞不定是因为有些字段做了索引。需要先扔掉索引,转码完了再重建。虽然有人教我,但我对自己能正确操作 mysql 还是没太大信心。就把仓库拖到本地,本地安装了一套 mysql8 做实验。

最后,结合网友的建议以及我自己的判断。我决定先以 binary 传输格式用 mysqldump 导出数据库(大约 500M),然后再用文本转换的方式替换其中的编码,最后再想办法导回。

mysqldump -u root -p --default-character-set=binary

这里导出命令行一定要加 --default-character-set=binary ,否则内码会被当成 latin 而且转换一次,数据是乱的。

一开始觉得挺简单的,查看了导出数据也很完成,不就是 iconv 转换一下么?实际操作发现 iconv 转换有很多错误。如果忽略掉错误,最后就无法导回数据库。我查了一下 dump 文件,发现数据库的数据中居然混杂着一些 utf8 字符串。iconv 无法正确处理这种混杂的编码。而且 mysql 会将部分字符转义,尤其是引号。如果编码转换中除了问题,就有可能吃掉某些引号等有关的格式文本,就变成了错误格式的文件。

所以全文文本替换是有巨大风险的。思来想去,我自己写了个 Lua 程序,最低限度的解析了 dump 文件的词法,只把 binary 字符串挑出来,并对转义符做好转义。将转换过的文本,用自己的代码判断它是 GBK 还是 UTF8 ,挑选出 GBK 交给 iconv 处理,而 UTF-8 则原封不动。最后再将字符串加回转义符,保证符合 mysql 语法。

最终找到了 680 条 UTF-8 文本。我猜测是当年有几天尝试过把 blog 数据转为 UTF-8 编码,又发现不太对劲所以换回来,中间产生的一些混杂编码。

对于转换好的数据,那些字段编码标准还是 latin ,所以用一个简单的文本替换成 utf-8 即可。

sed -i 's/CHARSET=latin1/CHARSET=utf8mb4/g' backup_utf8.sql
sed -i 's/COLLATE latin1_swedish_ci/COLLATE utf8mb4_unicode_ci/g' backup_utf8.sql

ps. 在本地 windows 上试验用 source 导入数据库时踩了个小坑。用反斜杠做路径会报错,必须用正斜杠绕开 mysql 的转义。


自此大功告成。

查看系统基本复原后,又连续升级了两个 LTS ,一直升级到 2024 LTS 版本。中间只碰到几个自己动过的软件配置文件问题。简单修一下即可。

估计又有十年可以不折腾它了。

立即模式下的鼠标交互处理

最近在做游戏时,发现在立即模式下鼠标的交互部分实现的比较混乱。

在做引擎时,我简单留出了鼠标相关事件的 callback 接口。一开始写游戏时,也就是对付一下写了几行代码,大致可以工作。做了大半个月后,随着交互界面越来越复杂,那些应付用的代码明显不堪重负。有越来越多的边界情况无法正确处理。等到最近想在交互上加一种长按鼠标确认的操作,发现不能再这样对付下去了,就花了一晚上重构了所有和鼠标交互相关的代码。

之前的问题出在哪里?

如果从系统的鼠标消息出发,我们可以从引擎获得鼠标移动、按下、抬起等事件。或许还可以利用系统发送来的点击、双击等等复合事件,但我选择自己用按下和抬起来判断这些。但是,消息机制本身和立即模式是相悖的。采用立即模式编写游戏业务以及交互,获得当下的状态是最自然的,而“消息”不是状态。它是一个队列:在每个游戏帧中,可能没有消息,也可能有多条消息。如果只是处理鼠标的位置和按键状态,那么保留最后一个状态也可以;但是,像点击这种行为,明显不是瞬间的状态,而是过去一段时间的状态叠加后的事件。

除了“点击”,必须处理的还有“焦点”,或叫“悬停”。一个交互元素获得焦点、失去焦点都不是瞬间状态,它们取决于鼠标过去的位置、鼠标位置下交互元素在屏幕上的位置。即使鼠标不移动,但交互元素在屏幕上动了,或消失了、出现了,都可能引起“焦点”的改变。

所以在立即模式下,最好我们可以将点击和焦点这样的“事件”变成某种“状态”,然后统一用立即模式处理。否则混用立即模式的状态判断和消息队列轮询就会比较混乱。

首先,我们应该把系统传来的鼠标消息在帧间累积起来,然后在每个游戏帧发送出去,而不应该在消息抵达的时候立即处理。这样做可以让游戏代码严格的按帧执行,帧间不会触发任何额外的 callback 。所以,在立即模式下,底层传来的不是鼠标移动消息,而是每帧鼠标的位置。即使没有更新位置,也同样会刷新一次鼠标位置信息。如果两帧之间有多个鼠标移动消息,位置当然只需要记录最后一次,游戏可以忽略中间的轨迹(除非以后要做鼠标手势,那再来改进)。

但鼠标按键则不可只保留多个按压抬起事件的最后一个。比如之前如果鼠标处于按下状态、而在两帧之间鼠标按键抬起又按下,如果只取最后的按键状态,就没有改变(都是按下状态),但操作者实际点击了一次鼠标。这个点击操作就被忽略掉了,这是不行的。

那应该怎么处理?首先,鼠标按键的状态和点击应该分离。如果游戏需要查询鼠标按键是抬起还是按下,那么和鼠标位置一样,每帧逻辑都会被推送这些状态信息。但鼠标的点击行为,应该是另一种独立状态:在鼠标的按键按下时,底层应该记录这个时刻的帧序号,并不立即通知游戏。而当鼠标抬起时,就改变了“鼠标点击”的状态。这个鼠标点击的状态为空时,表示点击并未发生,不为空时,状态值是点击的时长:即按下到抬起的帧数。

对于前面举例的情况,如果在帧间依次发生了抬起和按下,“鼠标点击”状态也会从空转换为这次点击的时长。同时底层会重置按下时刻,等待下一次抬起后再改变状态。在极端情况下,如果两帧之间连续发生了非常多次按下和抬起,我情况于只记录第一次的“鼠标点击”时长。除非以后要支持“双击”,那也是另一种手势,需要额外实现了。“鼠标点击”这个状态只会存在一帧,无论这一帧游戏代码有没有检查使用这个状态,该状态都会重置为空。

其次,我们需要一个焦点管理器。鼠标焦点永远只能在一个对象上。立即模式的交互层可以和立即模式的渲染层一样,每帧遍历所有的对象,渲染层将可渲染对象按层次和次序提交给底层;交互层则是按层次和次序依次判断每个对象是否获得了焦点。交互层和渲染层都和对象的屏幕空间位置有关,所以两者其实可以做到一起;当然也可以分开,因为未必所有的对象都同时需要渲染和鼠标交互。

对于焦点管理器,它可以每帧简单的把当前焦点对象以一个状态量提供。因为查询是哪个对象获得了焦点需要遍历一次所有对象,这在渲染时就会做一遍,所以一般我们可以将上一帧的焦点传给当前帧,交互在视觉上差一帧问题不是很大。

和查询当前鼠标按键状态一样,游戏逻辑可以查询当前的焦点是谁。但它和“鼠标点击”结合起来使用就不太方便。所谓鼠标点击,通常指鼠标按下的那一刻,鼠标焦点在一个对象上,而抬起时鼠标焦点还在同一个对象上,才能视为点击了这个对象。简单用鼠标抬起那一刻鼠标焦点的对象不太符合一般的使用习惯。所以,我们可以把“焦点”这个状态加上当前焦点对象获得焦点持续的帧数。这样,想知道“鼠标点击”发生时,是否真的点击了当前焦点对象,只需要比较两个时长即可:“鼠标点击”的时长不能大于“焦点”的持续时长。

有了以上这个基础,我们在编写游戏时就可以方便的以立即模式处理每帧的业务:获得当前帧鼠标的位置、按键状态、鼠标每个按键点击的状态(为空或一个时长)、当前鼠标焦点的状态(焦点对象及焦点持续时长)……

在这个基础上,还可以再做一些封装。因为某些模块从效率考虑并不适合每帧都刷新状态。比如交互界面,只有在焦点状态发生改变时,它的属性才会改变:按钮的视觉效果、屏幕提示文字、等等。这些属性改变的成本比较高,不适合每帧都重置。我们可以把每帧的鼠标焦点再记录下来,只有焦点发生改变时,才做额外处理。这个记录焦点状态变化的东西可以放在栈上的临时结构。立即模式比较适合以自然方式书写业务。

在 Lua 中定义类型的简单方法

我通常用 Lua 定义一个类型只需要这样做:

-- 定义一个 object 的新类型
local object = {}; object.__index = object

-- 定义构建 object 的函数
local function new_object(self)
  return setmetatable(self or {}, object)
end

-- 给 object 添加一个 get 方法
function object:get(what)
  return self[what]
end

-- 测试一下
local obj = new_object { x = "x" }
assert(obj:get "x" == "x")

这样写足够简单,如果写熟了就不用额外再做封装。如果一定要做一点封装,可以这样:

local class = {}; setmetatable(class, class)

function class:__index(name)
    local class_methods = {}; class_methods.__index = class_methods
    local class_object = {}
    local class_meta = {
        __newindex = class_methods,
        __index = class_methods,
        __call = function(self, init)
            return setmetatable(init or {}, class_methods)
        end
    }
    class[name] = setmetatable(class_object, class_meta)
    return class_object
end

封装的意义在于:你可以通过上面这个 class 模块定义新的类型,且能通过它用类型名找到所有定义的新类型。而上面的第一版通常用于放在独立模块文件中,依赖 lua 的模块机制找到 new_object 这个构建方法。

而封装后可以这样用:

-- 定义一个名为 object 的新类型,并添加 get 方法:

local object = class.object

function object:get(what)
    return self[what]
end

-- 创建新的 object 实例,测试方法 object:get
local obj = class.object { x = "x" }
assert(obj:get "x" == "x")

如果觉得 local object = class.object 的写法容易产生歧义,也可以加一点小技巧(同时提供特殊的代码文本模式,方便日后搜索代码):

function class:__call(name)
    return self[name]
end

-- 等价于 local object = class.object
local object = class "object" 

如果我们要定义的类型是一个容器该怎么做好?

容器的数据结构有两个部分:容纳数据的集合和容器的元数据。之前,我通常把元数据直接放在对象实例中,把集合对象看作元数据中的一个。

比如定义一个集合类型 set 以及两个方法 get 和 set :

local set = class "set"

function set:new()
    return self {
        container = {},
        n = 0,
    }
end

function set:set(key, value)
    local container = self.container
    if value == nil then
        if container[key] ~= nil then
            container[key] = nil
            self.n = self.n - 1
        end
    else
        if container[key] == nil then
            self.n = self.n + 1
        end
        container[key] = value
    end
end

function set:get(key)
    return self.container[key]
end

真正集合容器在 self.container 里,这里 self.n 是集合的元信息,即集合元素的个数。注意这里集合类型需要有一个构造函数 new ,因为它在构造实例时必须初始化 .n 和 .container 。这里的 set:new 构造函数调用了前面生成的 class.set 这个默认构造行为。

测试一下:注意这里用 class.set:new() 调用了构造函数。它等价于 class.set { container = {}, n = 0 } ,因为 .container 和 .n 属于实现细节,所以不推荐使用。

local obj = class.set:new()
obj:set("x", 1)
obj:set("y", 2)
assert(obj.n == 2)
assert(obj:get "x" == 1)

如果使用者要直接访问容器的内部数据结构,它可以用 obj.container 找到引用。但我们可能希望 set 表现得更像 lua table 一样,所以也可能想这样实现:

local set2 = class "set2"

function set2:new()
    return self {
        _n = 0,
    }
end

function set2:set(key, value)
    if value == nil then
        if self[key] ~= nil then
            self[key] = nil
            self._n = self._n - 1
        end
    else
        if self[key] == nil then
            self._n = self._n + 1
        end
        self[key] = value
    end
end

-- 测试一下

local obj = class.set2:new()
obj:set("x", 1)
obj:set("y", 2)
assert(obj._n == 2)
assert(obj.x == 1)

这个版本去掉了 .container 而直接把数据放在 self 里。所以不再需要 get 方法。为了让元数据 n 区分开,所以改为了 ._n 。


如果规范了命名规则,用下划线区分元数据未尝不是一个好的方法,但在迭代容器的时候会需要剔除它们比较麻烦。所以有时候我们会把元数据外置,这里就需要用到 lua 5.2 引入的 ephemeron table 来帮助 gc 。

local set3 = class "set3"

local SET = setmetatable({}, { __mode = "k" })

function set3:new()
    local object = self()
    SET[object] = { n = 0 }
    return object
end

function set3:set(key, value)
    if value == nil then
        if self[key] ~= nil then
            self[key] = nil
            SET[self].n = SET[self].n - 1
        end
    else
        if self[key] == nil then
            SET[self].n = SET[self].n + 1
        end
        self[key] = value
    end
end

function set3:__len()
    return SET[self].n
end

-- 测试一下:

local obj = class.set3:new()
obj:set("x", 1)
obj:set("y", 2)
assert(#obj == 2)
assert(obj.x == 1)

-- 迭代 obj 已经看不到元数据了。
for k,v in pairs(obj) do
    print(k,v)
end

由于 ._n 外部不可见,所以我们用 #obj 来获取它。


如果不想用 ephemeron table 管理元数据,是否有什么简单的方法剔除元数据呢?

最近发现另一个小技巧,那就是使用 false 作为元数据的 key :

local set4 = class "set4"

function set4:new()
    return self {
        [false] = 0,
    }
end

function set4:set(key, value)
    if value == nil then
        if self[key] ~= nil then
            self[key] = nil
            self[false] = self[false] - 1
        end
    else
        if self[key] == nil then
            self[false] = self[false] + 1
        end
        self[key] = value
    end
end

function set4:__len()
    return self[false]
end

-- 测试一下

local obj = class.set4:new()
obj:set("x", 1)
obj:set("y", 2)

for k,v in pairs(obj) do
    if k then
        print(k,v)
    end
end

这个版本几乎和第二版相同,不同的地方只是在于把 ["_n"] 换成了 [false] 。这里只有一个元数据,如果有多个,可以把 [false] = {} 设为一张表。

这样就不需要额外使用弱表,在迭代时也只需要判断 key 是否为真来剔除它。虽然有这么一点点局限,但贵在足够简单。


当然你也可以给它再定义一个 __pairs 方法滤掉 false :

function set4:next(k)
    local nk, v = next(self, k)
    if nk == false then
        return next(self, false)
    else
        return nk, v
    end
end

function set4:__pairs()
    return self.next, self
end

或者给加一种叫 class.container 的类型创建方法

local function container_next(self, k)
    local nk, v = next(self, k)
    if nk == false then
        return next(self, false)
    else
        return nk, v
    end
end

function class.container(name)
    local container_class = class[name]
    function container_class:__pairs()
        return container_next, self
    end
    return container_class  
end

如果你不需要 class 提供的默认构造函数,同时不喜欢定义一个新的 new 方法,也可以直接覆盖默认构造函数(同时避免别处再给它增加新的方法):

local set5 = class.container "set5"

function set5:set(key, value)
    if value == nil then
        if self[key] ~= nil then
            self[key] = nil
            self[false] = self[false] - 1
        end
    else
        if self[key] == nil then
            self[false] = self[false] + 1
        end
        self[key] = value
    end
end

function set5:__len()
    return self[false]
end

function class.set5()
    return set5 {
        [false] = 0,
    }
end

local obj = class.set5()
obj:set("x", 1)
obj:set("y", 2)

for k,v in pairs(obj) do
    print(k,v)
end

编写游戏程序的一些启示

这个月我开了个新项目:制作 deep future 的电子版。

之所以做这个事情,是因为我真的很喜欢这个游戏。而过去一年我在构思一个独立游戏的玩法时好像进入了死胡同,我需要一些设计灵感,又需要写点代码保持一下开发状态。思来想去,我确定制作一个成熟桌游的电子版是一个不错的练习。而且这个游戏的单人玩法很接近电子游戏中的 4x 类型,那也是我喜欢的,等还原了原版桌游规则后,我应该可以以此为基础创造一些适合电子游戏特性的东西来。

另一方面,我自以为了解游戏软件从屏幕上每个像素点到最终游戏的技术原理,大部分的过程都亲身实践过。但我总感觉上层的东西,尤其是游戏玩法、交互等部分开发起来没有底层(尤其是引擎部分)顺畅。我也看到很多实际游戏项目的开发周期远大于预期,似乎开发时间被投进了黑洞。

在 GameJam 上两个晚上可以做出的游戏原型,往往又需要花掉 2,3 年时间磨练成成品。我想弄清楚到底遇到了怎样的困难,那些不明不白消耗掉的开发时间到底去了哪里。

这次我选择使用前几个月开发的 soluna 作为引擎。不使用前些年开发的 Ant Engine 的原因 在这个帖子里写得很清楚了。至于为什么不用现成的 unreal/unity/godot 等,原因是:

  1. 我明白我要做什么事,该怎么做,并不需要在黑盒引擎的基础上开发。是的,虽然很多流行引擎有源码,但在没有彻底阅读之前,我认为它们对我的大脑还是黑盒。而阅读理解这些引擎代码工程巨大。

  2. 我的项目不赶时间,可以慢慢来。我享受开发过程,希望通过开发明白多一些道理,而不是要一个结果。我希望找到答案,可能可以通过使用成熟引擎,了解它们是怎样设计的来获得;但自己做一次会更接近。

  3. 自己从更底层开发可以快速迭代:如果一个设计模式不合适,可以修改引擎尝试另一个模式。而不是去追寻某个通用引擎的最佳实践。

  4. 我会使用很多成熟的开源模块和方案。但通常都是我已经做过类似的工作,期望可以和那些成熟模块的作者/社区共建。

  5. 这个项目几乎没有性能压力。我可以更有弹性的尝试不同的玩法。成熟引擎通常为了提升某些方面的性能,花去大量的资源做优化,并做了许多妥协。这些工作几乎是不可见的。也就是说,如果使用成熟引擎开发,能利用到的部分只是九牛一毛,反而需要花大量精力去学习如何用好它们;而针对具体需求自己开发,花掉的精力反而更有限,执行过程也更为有趣。

这篇 blog 主要想记录一下这大半个月以来,我是怎样迭代引擎和游戏的。我不想讨论下面列举出来的需求的最佳方案,现在已经完成的代码肯定不是,之后大概率也会再迭代掉。我这个月的代码中一直存在这样那样的“临时方案”、“全局状态”、甚至一些复制粘贴。它们可能在下一周就重构掉,也可能到游戏成型也放在那里。

重要的是过程应该被记录下来。


在一开始,我认为以立即模式编写游戏最容易,它最符合人的直觉:即游戏是由一帧帧画面构成的,只需要组帧绘制需要的画面就可以了。立即模式可以减少状态管理的复杂度。这一帧绘制一个精灵,它就出现在屏幕上;不绘制就消失了。

大部分成熟引擎提供的则是保留模式:引擎维护着一组对象集合,使用者创建或删除对象,修改这些对象的视觉属性。这意味着开发者需要做额外的很多状态管理。如果引擎维持的对象集合并非平坦结构,而是树状容器结构,这些状态管理就更复杂了。

之所以引擎喜欢提供保留模式大概是因为这样可以让实现更高效。而且在上层通过恰当的封装,立即模式和保留模式之间也是可以互相转换的。所以开发者并不介意这点:爱用立即模式开发游戏的人做一个浅封装层就可以了。

但我一开始就选择立即模式、又不需要考虑性能的话,一个只对图形 api 做浅封装的引擎直接提供立即模式最为简单。所以一开始,soluna 只提供了把一张图片和一个单独文字显示在屏幕特定位置的 api 。当然,使用现代图形 api ,给绘制指令加上 SRT 变换是举手之劳。(在 30 年前,只有一个 framebuffer 的年代,我还需要用汇编编写大量关于旋转缩放的代码)

在第一天,我从网上找来了几张卡牌的图片,只花了 10 分钟就做好了带动画和非常简单交互的 demo 。看起来还很丝滑,这给我不错的愉悦感,我觉得是个好的开始。

想想小丑牌也是用 Love2D 这种只提供基本 2d 图片渲染 api 的引擎编写出来的,想来这些也够用了。当然,据说小丑牌做了三年。除去游戏设计方面的迭代时间外,想想程序部分怎么也不需要这么长时间,除非里面有某些我察觉不到的困难。

接下来,我考虑搭一些简单的交互界面以及绘制正式的卡牌。

Deep future 的卡牌和一般的卡牌游戏还不一样。它没有什么图形元素,但牌面有很多文字版面设计。固然,我可以在制图设计软件里定下这些版面的位置,然后找个美术帮我填上,如果我的团队有美术的话……这是过去在商业公司的常规做法吧?可是现在我一个人,没有团队。这是一件好事,可以让我重新思考这个任务:我需要减少这件我不擅长的事情的难度。我肯定会大量修改牌面的设计,我得有合适我自己的工作流。

在 Ant 中,我们曾经集成过 RmlUI :它可以用 css 设计界面。css 做排版倒是不错,虽然我也不那么熟悉,但似乎可以完成所有需求。但我不喜欢写 xml ,也不喜欢 css 的语法,以及很多我用不到的东西。所以,我决定保留核心:我需要一个成熟的排版用的结构化描述方案,但不需要它的外表。

所以我集成了 Yoga ,使用 Lua 和我自己设计的 datalist 语言来描述这个版面设计。如果有一天,我想把这个方案推广给其他人用,它的内在结构和 css 是一致的,写一个转换脚本也非常容易。

暂时我并不需要和 Windows 桌面一样复杂的界面功能。大致上有单个固定的界面元素布局作为 HUD (也就是主界面)就够了。当然,用 flexbox 的结构来写,自动适应了不同的分辨率。采用这种类 CSS 的排版方案,实际上又回到了保留模式:在系统中保留一系列的需要排版布局的对象。

当我反思这个问题时,我认为是这样的:如果一个整体大体是不变的,那么把这个整体看作黑盒,其状态管理被封装在内部。使用复杂度并没有提高。这里的整体就是 HUD 。考虑到游戏中分为固定的界面元素和若干可交互的卡片对象,作为卡牌游戏,那些卡牌放在 HUD 中的容器内的。如果还是用同样的方案管理卡片的细节,甚至卡片本身的构图(它也是由更细分的元素构成的)。以保留模式整个管理就又变复杂了。

所以,我在 yoga 的 api 封装层上又做了一层封装。把界面元素分为两类:不变的图片和文字部分,和需要和玩家交互的容器。容器只是由 yoga 排版的一个区域,它用 callback 的形式和开发者互动就可以了。yoga 库做的事情是:按层次结构遍历处理完整个 DOM ,把所有元素平坦成一个序列,每个元素都还原成绝对坐标和尺寸,去掉层次信息,只按序列次序保留绘制的上下层关系。在这个序列中,固定的图片和文字可以直接绘制,而遇到互动区,则调用用户提供的函数。这些函数还是以立即模式使用:每帧都调用图形 API 渲染任意内容。

用下来还是挺舒服的。虽然 callback 的形式我觉得有点芥蒂,但在没找到更好的方式前先这么用着,似乎也没踩到什么坑。


渲染模块中,一开始只提供了文字和图片的渲染。但我留出了扩展材质的余地。文字本身就是一种扩展材质,而图片是默认的基础材质。做到 UI 时,我发现增加一种新的材质“单色矩形”特别有用。

因为我可以在提供给 yoga 的布局数据中对一些 box 标注,让它们呈现出不同颜色。这可以极大的方便我调试布局。尤其是我对 flexbox 布局还不太熟练的阶段,比脑补布局结果好用得多。

另一个有用的材质是对一张图片进行单色渲染,即只保留图片的 alpha 通道,而使用单一颜色。这种 mask 可以用来生成精灵的阴影,也可以对不规则图片做简单遮罩。

在扩展材质的过程中,发现了之前预留的多材质结构有一些考虑不周全的设计,一并做了修改。


到绘制卡牌时,卡牌本身也有一个 DOM ,它本质上和 HUD 的数据结构没什么区别,所以这个数据结构还是嵌套了。一开始,我在 soluna 里只提供了平坦的绘制 api ,并没有层次管理。一开始我做的假设是:这样应该够用。显然需要打破这个假设了。

我给出的解决方案是:在立即模式下,没必要提供场景树管理,但可以给一个分层堆栈。比如将当前的图层做 SRT 变换,随后的绘图指令都会应用这套变换,直到关闭这个图层(弹出堆栈)。这样,我想移动甚至旋转缩放 HUD 中的一个区域,对于这个区域的绘制指令序列来说都是透明的:只需要在开始打开一个新图层,结束时关闭这个图层即可。

另一个需求是图文混排,和文字排版。一开始我假设引擎只提供单一文字渲染的功能就够用,显然是不成立的。Yoga 也只提供 box 的排版,如果把每个单字都作为一个 box 送去 yoga 也不是不行,但直觉告诉我这不但低效,还会增加使用负担。web 上也不是针对每个单字做排版的。用 Lua 在上层做图片和文字排版也可以,但对性能来说太奢侈了。

这是一个非常固定的需求:把一块有不同颜色和尺寸的文字放在一个 box 中排版,中间会插入少许图片。过去我也设计过不少富文本描述方案,再做一次也不难。这次我选择一半在 C 中实现,一半在 Lua 中实现。C 中的数据结构利于程序解析,但书写起来略微繁琐;Lua 部分承担易于人书写的格式到底层富文本结构的转换。Lua 部分并不需要高频运行,可以很方便的 cache 结果(这是 Lua 所擅长的),所以性能不是问题。

至于插入的少许图片,我认为把图片转换为类似表情字体更简单。我顺手在底层增加了对应的支持:用户可以把图片在运行时导入字体模块。这些图片作为单独的字体存在,codepoint 可以和 unicode 重叠。并不需要以 unicode 在文本串中编码这些图片,而将编码方式加入上述富文本的结构。


在绘制文本的环节,我同时想到了本地化模块该如何设计。这并非对需求的未雨绸缪,而是我这些年来一直在维护群星的汉化 mod 。非常青睐 Paradox 的文本方案。这不仅仅是本地化问题,还涉及游戏中的文本如何拼接。尤其是卡牌游戏,关于规则描述的句子并非 RPG 中那样的整句,而是有很多子句根据上下文拼接而来的。

拼句子和本地化其实是同一个问题:不同语言间的语法不同,会导致加入一些上下文的句子结构不同。P 社在这方面下了不少功夫,也经过了多年的迭代。我一直想做一套类似的系统,想必很有意思。这次了了心愿。

我认为代码中不应该直接编码任何会显示出来的文本,而应该统一使用点分割的 ascii 字串。这些字串在本地化模块那里做第一次查表转换。

有很大一部分句子是由子句构成的,因为分成子句和更细分的语素可以大大降低翻译成不同语言的工作量。这和代码中避免复制粘贴的道理是一样的:如果游戏中有一个术语出现在不同语境下,这个术语在本地化文本中只出现在唯一地方肯定最好。所以,对于文本来说,肯定是大量的交叉引用。我使用 $(key.sub.foobar) 的方式来描述这种交叉引用。注:这相当于 P 社语法中的 $key.sub.foobar$ 。我对这种分不清开闭的括号很不感冒。

另一种是对运行环境中输入的文本的引用:例如对象的名字、属性等。我使用了 ${key} 这样的语法,大致相当于 P 社的 [key] 。但我觉得统一使用 $ 前缀更好。至于图标颜色、字体等标注,在 P 社的语法中花样百出,我另可使用一致的语法:用 [] 转义。

这个文本拼接转换的模块迭代了好几次。因为我在使用中总能发现不完善的实现。估计后面还会再改动。好在有前人的经验,应该可以少走不少弯路吧。


和严肃的应用不同,游戏的交互是很活泼的。一开始我并没有打算实现元素的动画表现,因为先实现功能仿佛更重要。但做着做着,如果让画面更活泼一点似乎心情更愉悦一点。

比如发牌。当然可以直接把发好的牌画在屏幕指定区域。但我更希望有一个动态的发牌过程。这不仅仅是视觉感受,更能帮助不熟悉游戏规则的玩家尽快掌控卡牌的流向。对于 Deep Future 来说更是如此:有些牌摸出来是用来产生随机数的、有些看一眼就扔掉了、不同的牌会打在桌面不同的地方。如果缺少运动过程的表现,玩家熟悉玩法的门槛会高出不少。

但在游戏程序实现的逻辑和表现分离,我认为是一个更高原则,应尽可能遵守。这部分需要一点设计才好。为此,我并没有草率给出方案尽快试错,而是想了两天。当然,目前也不是确定方案,依旧在迭代。

css 中提供了一些关于动画的属性,我并没有照搬采用。暂时我只需要的运动轨迹,固然轨迹是对坐标这个属性的抽象,但一开始没必要做高层次的抽象。另外,我还需要保留对对象的直接控制,也就是围绕立即模式设计。所以我并没有太着急实现动画模块,而且结合另一个问题一起考虑。

游戏程序通常是一个状态机。尤其是规则复杂的卡牌游戏更是。在不同阶段,游戏中的对象遵循不同的规则互动。从上层游戏规则来看是一个状态机,从底层的动画表现来看也是,人机交互的界面部分亦然。

从教科书上搬出状态机的数据结构,来看怎么匹配这里的需求,容易走向歧途;所以我觉得应该先从基本需求入手,不去理会状态机的数据结构,先搭建一个可用的模块,再来改进。

Lua 有 first class 的 coroutine ,非常适合干这个:每个游戏状态是一个过程(相对一帧画面),有过程就有过程本身的上下文,天然适合用 coroutine 表示。而底层是基于帧的,显然就适合和游戏的过程分离开。

以发牌为例:在玩家行动阶段,需要从抽牌堆发 5 张牌到手牌中。最直接的做法是在逻辑上从牌堆取出 5 张牌,然后显示在手牌区。

我需要一个发牌的视觉表现,卡牌从抽牌堆移动到手牌区,让玩家明白这些牌是从哪里来的。同时玩家也可以自然注意到在主操作区(手牌区)之外还有一个可供交互的牌堆。

用立即模式驱动这个运动轨迹,对于单张牌来说最为简单。每帧计算牌的坐标,然后绘制它就可以了。但同时发多张牌就没那么直接了。

要么一开始就同时记录五张牌的目的地,每帧计算这五张牌的位置。这样其实是把五张牌视为整体;要么等第一张牌运动到位,然后开始发下一张牌。这样虽然比较符合现实,但作为电子游戏玩,交互又太啰嗦。

通常我们要的行为是:这五张牌连续发出,但又不是同时(同一帧)。牌的运动过程中,并非需要逐帧关注轨迹,而只需要关注开始、中途、抵达目的地三个状态。其轨迹可以一开始就确定。所以,卡牌的运动过程其实处于保留模式中,状态由系统保持(无需上层干涉),而启动的时机则交由开发者精确控制更好。至于中间状态及抵达目的地的时机,在这种对性能没太大要求的场景,以立即模式逐帧轮询应无大碍(必须采用 callback 模式)。

也就是,直观的书写回合开始的发牌流程是这样的:

for i = 1, 5 do
  draw_card() -- 发一张牌
  sleep(0.1)  -- 等待 0,1 秒
end

这段代码作为状态机逻辑的一部分天然适合放在单独的 coroutine 中。它可以和底层的界面交互以及图形渲染和并行处理。

而发牌过程,则应该是由三个步骤构成:1. 把牌设置于出发区域。2. 设定目的地,发起移动请求。3. 轮询牌是否运动到位,到位后将牌设置到目的地区域。

其中步骤 1,2 在 draw_card 函数中完成最为直观,因为它们会在同一帧完成。而步骤 3 的轮询应该放在上述循环的后续代码。采用轮询可以避免回调模式带来的难以管理的状态:同样符合直观感受,玩家需要等牌都发好了(通常在半秒之内)再做后续操作。

我以这样的模式开发了一个基于 coroutine 的简单状态机模块。用了几天觉得还挺舒适。只不过发现还是有一点点过度设计。一开始我预留了一些 api 供使用者临时切出当前状态,进入一个子状态(另一个 coroutine),完成后再返回;还有从一个过程中途跳出,不再返回等等。使用一段时间以后,发现这些功能是多余的。后续又简化掉一半。

至于动画模块,起初我认为一切都围绕卡牌来做就可以了。可以运动的基本元素就是不同的卡片。后来发现其实我还需要一些不同于卡片的对象。运动也不仅仅是位移,还包括旋转和缩放,以及颜色的渐变。

至于对象运动的起点和终点,都是针对的前面所述的“区域”这个概念。一开始“区域”只是一个回调函数;从这里开始它被重构成一个对象,有名字和更多的方法。“区域”也不再属于同一个界面对象,下面会谈到:我一开始的假设,所有界面元素在唯一 DOM 上,感觉是不够用的。我最终还是需要管理不同的 DOM ,但我依旧需要区域这个概念可以平坦化,这样可以简化对象可以在不同的 DOM 间运动的 API。

运动过程本身,藏在较低的层次。它是一个独立模块,本质上是以保留模式管理的。在运动管理模块中,保留的状态仅仅是时间轴。也就是逐帧驱动每个运动对象的时间轴(一个数字)。逐帧处理部分还是立即模式的,传入对象的起点和终点,通过时间进度立即计算出当前的状态,并渲染出来。


从状态管理的角度看,每帧的画面和动画管理其实并不是难题。和输入相关的交互管理更难一些,尤其是鼠标操作。对于键盘或手柄,可以使用比较直观的方式处理:每帧检查当下的输入内容和输入状态,根据它们做出反应即可。而鼠标操作天生就是事件驱动的,直到鼠标移动到特定位置,这个位置关联到一个可交互对象,鼠标的点击等操作才有了特别的含义。

ImGUI 用了一种立即模式的直观写法解决这个问题。从使用者角度看,它每帧轮询了所有可交互对象,在绘制这些对象的同时,也依次检查了这些对象是否有交互事件。我比较青睐这样的用法,但依然需要做一些改变。毕竟 ImGUI 模式不关注界面的外观布局,也不擅长处理运动的元素。

我单独实现了一个焦点管理模块。它内部以保留模式驱动界面模块的焦点响应。和渲染部分一样,处理焦点的 API 也使用了一些 callback 注入。这个模块仅管理哪个区域接收到了鼠标焦点,每个区域通过 callback 函数再以立即模式(轮询的方式)查询焦点落在区域内部的哪个对象上。

在使用层面,开发者依然用立即模式,通过轮询获取当前的鼠标焦点再哪个区域哪个对象上;并可查询当前帧在焦点对象上是否发生了交互事件(通常是点击)。这可以避免用 callback 方式接收交互事件,对于复杂的状态机,事件的 callback 要难管理的多。

一开始我认为,单一 HUD 控制所有界面元素就够了。只需要通过隐藏部分暂时不用的界面元素就可以实现不同游戏状态下不同的功能。在这个约束条件下,代码可以实现的非常简单。但这几天发现不太够用。比如,我希望用鼠标右键点击任何一处界面元素,都会对它的功能做一番解说。这个解说界面明显是可以和主界面分离的。我也有很大意愿把两块隔离开,可以分别独立开发测试。解说界面是帮助玩家理解游戏规则和交互的,和游戏的主流程关系不大。把它和游戏主流程放在一起增加了整体的管理难度。但分离又有悖于我希望尽可能将对象管理平坦化的初衷,我并不希望引入树状的对象层次结构。

最近的设计灵感和前面绘制模块的图层设计类似,我给界面也加入了图层的概念。永远只有一个操作层,但层次之间用栈管理。在每个状态看到的当下,界面的 DOM 都是唯一的。状态切换时则可以将界面压栈和出栈。如果后续不出现像桌面操作系统那样复杂的多窗口结构的话,我想这种栈结构分层的界面模式还可以继续用下去。

另一个变动是关于“区域”。之前我认为需要参与交互的界面元素仅有“区域”,“区域”以立即模式自理,逐帧渲染自身、轮询焦点状态处理焦点事件。最近发现,额外提供一种叫“按钮”的对象会更方便一些。“按钮”固然可以通过“区域”来实现,但实践中,处理“按钮”的不是“按钮”本身,而是容纳“按钮”的容器,通常也是最外层的游戏过程。给“按钮”加上类似 onclick 的 callback 是很不直观的;更直观的做法是在游戏过程中,根据对应的上下文,检查是否有关心的按钮被点击。

所有的按钮的交互管理可以放在一个平坦的集合中,给它们起上名字。查询时用 buttons.click() == "我关心的按钮名字" 做查询条件,比用 button_object.click() 做查询条件要舒服一点。


以上便是最近一个月的部分开发记录。虽然,代码依旧在不断修改,方案也无法确定,下个月可能还会推翻目前的想法。但我感觉找到了让自己舒适的节奏。

不需要太着急去尽快试错。每天动手之前多想想,少做一点,可以节省很多实作耗掉的精力;也不要过于执著于先想清楚再动手,毕竟把代码敲出带来的情绪价值也很大。虽然知道流畅的画面背后有不少草率的实现决定,但离可以玩的游戏更进一步的心理感受还是很愉悦的。

日拱一卒,功不唐捐。

❌