普通视图

发现新文章,点击刷新页面。
今天 — 2025年9月16日云风的 BLOG

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

作者 云风
2025年9月16日 13:05

好消息是:这个 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 版本。中间只碰到几个自己动过的软件配置文件问题。简单修一下即可。

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

昨天以前云风的 BLOG

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

作者 云风
2025年9月9日 17:26

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

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

之前的问题出在哪里?

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

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

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

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

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

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

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

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

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

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

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

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

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

作者 云风
2025年8月26日 08:37

我通常用 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

编写游戏程序的一些启示

作者 云风
2025年8月23日 01:14

这个月我开了个新项目:制作 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() 做查询条件要舒服一点。


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

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

日拱一卒,功不唐捐。

SetWindowText 引起的死锁

作者 云风
2025年8月9日 10:51

最近发现我在写的小游戏在启动时有很小的概率黑屏。我使用的是 ltask 多线程框架,在黑屏时感觉 ltask 并没有停止工作,似乎只是管理窗口的部分(线程/服务)卡死了。

窗口管理使用的是 sokol_app 做的多平台封装,这只是一个很浅的封装层,但已经够用。我觉得美中不足的是,sokol_app 的 frame 回调函数是放在 WinProc 中,由 Windows 的消息循环被动调度,而不是放在外层的主动 GetMessage 循环中。

即,在 Windows 程序中,线程通常会在最外面写一个这样的 while 循环:

for (;;) {
    while (PeekMessageW(&msg, NULL, 0, 0, PM_REMOVE)) {
        if (msg.message == WM_QUIT)
            return;
        TranslateMessage(&msg);
        DispatchMessage(&msg);
    }

    // 在这里,我们可以做一些额外的工作,比如渲染游戏画面、处理游戏逻辑。
}

但我们也可以选择在窗口的 WinProc 中,通过响应 WM_TIMER 等消息的方式来做这些工作:

LRESULT CALLBACK wndproc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam) {
    switch (uMsg) {
        case WM_TIMER :
            // 在这里,可以定时做一些工作。
        break;
    }
    return DefWindowProcW(hWnd, uMsg, wParam, lParam);
}

// 外面的消息处理循环则可以使用 GetMessage 而不是 PeekMessage

while(GetMessage(&msg, NULL, 0, 0) {
    TranslateMessage(&msg);
    DispatchMessage(&msg);
}

无可厚非,后一种方法显得更正规一点:让 Windows 自身调度所有任务,系统如果做的正确,和系统的窗口系统本身契合的更好一点。这个模式是 Window 的历史设计造成的。把窗口系统的工作流程放在用户线程内,用户的程序其它部分配合它,换取交互的流畅度。

但是,一旦采用多线程设计,就变得有点不同了。窗口只是多线程任务的一部分,需要一个更高阶的框架来调度任务,例如 ltask 干的那些。通过在 WinProc 中处理对应消息,在没有消息进入的时候,线程会堵塞在 GetMessage 函数中。这对 ltask 这样的调度器来说非常的不友好。通常一个任务调度器需要的行为是:每个任务要么完成,要么让出,而不是阻塞。Windows 的 GetMessage/DispatchMessage 也是这样的循环,只不过是单线程的。

ltask 处理这样的模块,也不是完全没有办法。这得益于 ltask 的任务都运行在 lua 虚拟机上,和 C 层有一定的隔离。对于 C 代码来说,stack 是绑定在线程上的,所以无法在一个线程运行一半,然后在另一个线程继续工作(因为 stack 不同);但 Lua 的 stack 在 heap 上,迁移完全没有问题。

我曾经做过类似的尝试 ,但最终又从 ltask 主干上撤销了这个特性。倒不是实现的不对,而是配合它使用的 C 代码如果重入问题解决不好,隐藏的 bug 很难发现。这需要 C 部分最好在设计时就考虑过并行/重入问题。sokol 显然不是这样设计的。


为了让 sokol 可以在 ltask 下工作,我做了不少工作。sokol_gfx 的图形 api 部分倒是简单,我只需要保证在同一个服务中调用就可以了;比较麻烦的是 sokol_app 中处理窗口的部分。直接让 frame 回调函数运行在 ltask 的一个服务中非常困难。原因上面已述:这个回调函数结束后线程会挂起在 Windows 的消息处理循环中,而没有将控制权归还 ltask 。虽然可以通过 ltask 那个实验特性解决这个问题,但 sokol 并没有为多线程设计,很可能隐藏多线程 bug ,一旦出现难以调试。

我试过几个方案后,最终采用了最简单粗暴的方法:利用锁来同步任务。也就是在 frame callback 开始时抛出一个消息,并阻塞在一个锁上。这个消息会开启另一个 ltask 掌握的线程中对应的 render 服务;而在 render 服务渲染完当前帧,解开这个锁,frame callback 就会顺利返回。

在绝大多数场景中,这个方案工作的很好。但我最近偶尔发现在启动程序时,会有很小的概率,锁并没有解开。

一开始我并不为意,觉得或许是一些同步代码没有写好,因为有更想做的特性要开发,这种偶发死锁 bug 出现概率很低,且只出现在启动阶段,想着有空稍微复查一下启动代码就能解决。

这两天感觉的确“有空”了,花了一晚上,终于定位了问题。

问题出在游戏启动阶段改变窗口的标题上。固然,可以在窗口创建时就把标题设置好。但标题需要根据多语言环境设置不同的文本,处理多语言文本的这块逻辑不算简单,我不想放在启动的最初阶段(创建窗口之前),所以窗口创建时使用了一段默认文本,之后才修改它。

sokol_app 的 api 只是间接调用了 SetWindowTextW() 。显然不是 sokol_app 的封装问题。我查阅了 msdn ,发现 SetWindowTextW 只是给 WinProc 发送了一个 WM_SETTEXT 消息。也就是说,等价于调用 SendMessageW()

如果在 WinProc 所在线程中调用它当然没有问题,只是引起了 WinProc 重入:调用方在 frame callback 内,而 frame callback 处于 WinProc 的 WM_TIMER 的消息处理环节。这时调用 SetWindowTextW 等于递归再运行一次 WinProc 本身,但消息变成了 WM_SETTEXT ,新的调用返回后窗口的标题栏就被改变了。

可是,我现在在另外一个线程调用 SetWindowTextW 行为有所不同。这时 WM_SETTEXT 被投递到窗口消息处理线程,它需要排队等待 WinProc 再次被处理,也就是外层循环的下一次 DispatchMessage 调用。但是,这个时候当下的 DispatchMessage 还阻塞在 frame callback 的锁上面无法返回。这就是死锁产生的原因:

  1. DispatchMessage 调用 WinProc 处理 WM_TIMER 消息,它调用了 sokol 的 frame callback 。我的程序在 frame callback 中发出消息唤醒真正的处理流程,并等待在锁上。
  2. 真正的处理流程运行在另外线程,它调用了 SetWindowTextW ,其通过 SendMessageW 投递 WM_SETTEXT 到窗口线程的消息队列,等待返回。
  3. 窗口线程需要等当前的 WM_TIMER 处理完毕才 DispatchMessage 才可以结束,后续的 GetMessage 才可以拿到 WM_SETTEXT 消息处理它。

了解了死锁的原因后,最直接的解决方案是在窗口线程调用 SetWindowTextW 。因为这样会直接运行设置文本的逻辑,消息不需要进入消息队列,当然就没有锁的问题。但这个方案不适合现在的 ltask 框架。目前窗口线程不在 ltask 的管辖之下,也就无法在 lua 服务中调用 SetWindowTextW ,也无法直接通过 ltask 内部的消息把这个任务传递过去。

比如容易想到的是:“改变窗口标题”这个行为并不需要等待结果。那么是不是可以改用 PostWindowTextW 发送 WM_SETTEXT 就可以不阻塞调用方了呢?

答案是不行,原因在这里有解释 。因为这条消息发送了一个字符串,这里存在这个字符串生命期管理的问题,为了减少使用错误,Windows 禁止用 PostMessage 发送这样有生命期管理问题的系统消息。只有 SendMessage 可以在结果返回后正确释放消息文本所占用的内存。

所以,我们可以用独立线程通过 SendMessage 投递这个消息,并等待其返回后做完后续(生命期管理)工作。在 C 中创建新线程非常麻烦,但在 ltask 中却非常容易。只需要用一个独立的服务调用 SetWindowTextW 就够了。frame 的处理流程所在的服务/线程向它投递一个 ltask 消息,通知这个独立服务改变窗口标题,就不会阻塞 frame 流程。

慢跑

作者 云风
2025年7月20日 12:21

我这两年攀岩时总是体力不够用,出去野攀如果需要先爬山接近的话,往往爬到岩壁下就累个半死不想动了。而我那帮 50 多岁的岩友一个个都比我有活力的多。所以我想通过有氧运动改善一下心肺功能。岩友建议我试试慢跑。

去年底痛风发作 后也考虑过减少一些体重,据说有利于降低尿酸。但有点懒就一直没有开始跑。

我的身体状态是这样的:

目前身高 187 ,大学毕业时大约 183 ,后来 20 多年陆续又长了几厘米。大学刚毕业时体重只有 71 kg ,非常瘦。在 2002 年左右开始去健身房撸铁增肌,最高长到过 78kg 。后来去杭州没那么勤快了,又掉下来不少。到 2011 年回到广州时只剩下 74kg 不到。当时身高 185 - 186 之间,后来这 15 年又长了点身高,体重却在孩子出生后暴增,最高到过 90 kg 以上 。

前几年有一段时间,我自己在家做 HIIT 希望可以减重。2020 年时,因为尿路结石看了急症 。之后改做跳绳(希望可以排石),最后体重降到了 84 kg 。

最近一年因为不再上班工作了,除了偶尔(一周两到三次)出门去岩馆攀岩,几乎都在家里。体重在 3 个月前又升到了 91kg 。


大约在两个半月前,我下决心增加一些运动量。除了每周三次的攀岩外,另外四天每天做半个小时以上的慢跑。听取岩友建议,买了双软底的跑步鞋(体重较大,应重点保护膝盖)。选择在家旁边的公园,有质地比较软的跑步道。根据网上信息的测算,根据我的年龄,应该在慢跑时把心率控制在 140 以下。配速不重要,重要的是心率以及每次的时长(不低于 30 分钟),并避免受伤。

两个多月之前,我第一次尝试时,跑到 600 米左右,心率就超过了 150 ,赶紧停下来走路休息。

到现在坚持了两个多月,已经成为习惯。今天刮完台风,特别凉快。跑步时状态很好。第一公里用时 7 分钟,最后心率升到 140 。如果连续再跑下去还会上升,所以我选择走路休息到心率下降到 120 再继续。如此把心率维持在 120~140 之间,半个小时大约可以跑 3.5km 。

跑完再快走 5 分钟左右回家,不太觉得累。相比刚开始跑步时,到家就想躺下休息。这段时间在岩馆更也有动力爬。有岩友称,你终于有点老岩友的样子了。

至于体重,最近三天都在 86kg ,从数字上看已经减少了 5kg 。

控制尿酸方面:过去尿酸在 600 以上(体检报告记录)。现在戒掉了平时爱喝的含糖饮料,只在攀岩时喝一些运动饮料补充体力。日常喝苏打汽水(碱性),虽然以前也没有过多吃海鲜,现在是几乎不碰了。没有吃降尿酸的药。最近尿酸日常在 450 ~ 550 之间(每两天自测一次)。高低感觉和休息状态有关。如果白天过于劳累,晚上又没有好好休息的话,尿酸值也会明显升高。

脚没有再疼过,但总有点隐隐的感觉,可能是心理作用罢了。如果明年还不能降到 400 以下,考虑吃点药。


我知道跑步锻炼是一个漫长的过程,无法立竿见影。等半年以后再追加记录。

极度未来( Deep Future )给我的启发

作者 云风
2025年7月13日 12:50

最近我在 bgg 上闲逛时了解到了“Make-as-You-Play”这个游戏子类型,感觉非常有趣。它是一种用纸笔 DIY (或叫 PnP Print and Play)的游戏,但又和传统 DIY 游戏不同,并不是一开始把游戏做好然后再玩,而是边做边玩。对于前者,大多数优秀的 PnP 都有专业发行商发行,如果想玩可以买一套精美的制成品;但 Make as You Play 不同,做的过程是无法取代的,做游戏就是玩的一部分。

极度未来 Deep Future 是“做即是玩”类型的代表作。它太经典了,以至于有非常多的玩家变体、换皮重制。我玩的官方 1.6 版规则。btw ,作者在 bgg 上很活跃,我在官方论坛八年前的规则讨论贴上问了个规则细节:战斗阶段是否可以不损耗人口“假打”而只是为了获得额外加成效果。作者立刻就回复了,并表示会在未来的 1.7 规则书上澄清这一点。

读规则书的确需要一点时间,但理解了游戏设计精神后,规则其实都很自然,所以游戏进程会很流畅。不过依然有许多细节分散在规则书各处,只有在玩过之后才会注意到。我(单人)玩了两个整天,大约玩了接近 100 局,酣畅淋漓。整个游戏的过程有如一部太空歌剧般深深的刻印在我的脑海里,出生就灭亡的文明、离胜利只有一步之遥的遗憾、兴起衰落、各种死法颇有 RogueLike 游戏的精神。难怪有玩家会经年玩一场战役,为只属于自己战役的科技和文明设计精美的卡片。

在玩错了很多规则细节后,我的第一场战役膨胀到了初始卡组的两倍,而我也似乎还无法顺利胜利哪怕一局。所以我决定重开一盒游戏。新的战役只用了 5 盘就让银河推进到了第二纪元(胜利一次),并在地图上留下了永久印记,并制作了第一张文明卡片。这些会深刻的影响同场战役的后续游戏进程。

我感觉这就是这类游戏的亮点:每场游戏都是独特的。玩的时间越长,当前游戏宇宙的特点就有越来越深刻的理解:宇宙中有什么特别的星球、科技、地图的每个区域有不同的宜居星球密度,哪里的战斗强度会更大一些…… 虽然我只玩了单人模式,但游戏支持最多三人。多人游戏可以协作也可以对抗。你可以邀请朋友偶尔光临你的宇宙玩上两盘,不同的玩家会为同一个宇宙留下不同的遗产。和很多遗产类游戏不同,这个游戏只要玩几乎一定会留下点什么,留不下遗产的游戏局是及其罕见的。也就是说,只要玩下去哪怕一小盘都会将游戏无法逆转的改变。


下面先去掉细节,概述一下游戏规则:

游戏风格类似太空版文明,以一张六边形作为战场。这是边长为 4 的蜂巢地图(类似扩大一圈的卡坦岛),除去无法放置人口方块的中心黑洞,一共是 36 个六边形区格。玩家在以一个母星系及三人口开局,执行若干轮次在棋盘上行动。可用行动非常类似 4X 游戏:生产、探索、繁殖、发展、进攻、殖民。

每个玩家有 4 个进度条:文化 C、力量 M 、稳定 S 、外星 X。除去文化条外,其余三个条从中间开始,一旦任意一条落到底就会失败;而任意一条推进到顶将可能赢得游戏。文化条是从最底部开始,它推进到顶(达成文化胜利)需要更多步数,但没有文化失败。

另外,控制 12 个区域可获得疆域胜利,繁殖 25 个人口可获得人口胜利。失去所有星球也会导致失败。在多人模式中,先失败的玩家可以选择在下个回合直接在当前游戏局重新开始和未失败的玩家继续游戏(但初始条件有可能比全新局稍弱)。

游戏以纯卡牌驱动,每张卡片既是行动卡,又是系统事件卡,同时卡片还是随机性的来源。抽取卡片用于产生随机性的点数分布随着游戏发展是变化的,每场战役都会向不同的方向发展,这比一般的骰子游戏的稳定随机分布会多一些独有的乐趣。

玩家每轮游戏可作最多两个独立且不同的行动:

  • POWER 抽两张卡
  • ADVANCE 发展一项科技
  • GROW 繁殖两个人口
  • EXPAND 向临接空格移动任意数量人口,但至少在出发地留一个
  • BATTLE 和临接空格交战,或(当没有任何邻接敌人时)推进任意进度条
  • SETTLE 在有人口的区域殖民一个星球
  • EVOKE 打出一张文明卡
  • PLAN 制造一张新的指定行动卡

在执行这些行动的同时,如果玩家拥有更多的科技,就可能有更多的行动附加效果。这些科技带来的效果几乎是推进胜利进度条的全部方法,否则只有和平状态的 BATTLE 行动才能推进一格进度条。

在行动阶段之后,系统会根据玩家帝国中科技卡的数量产生不同数量的负面事件卡。科技卡越多,面临的挑战越大。但可以用手牌支付科技的维护费来阻止科技带来的额外负面事件,或用手牌兑换成商品寄存在母星和科技卡上供未来消除负面事件使用。

负面事件卡可能降低玩家的胜利进度条,最终导致游戏失败;也可能在地图增加新的野生星球及野怪。后者可能最终导致玩家失去已殖民的星球。但足够丰富的手牌以及前面用手牌制造的商品和更多的殖民星球可以用来取消这些负面事件。

每张星球卡和科技卡上都有三个空的科技栏位,在生成卡片时至少会添加一条随机科技,而另两条科技会随着游戏进程逐步写上去。

游戏达成胜利的必要条件是玩家把母星的三条科技开发完,并拥有至少三张完成的科技卡(三条科技全开发完毕),然后再满足上面提到的 6 种胜利方式条件之一:四个胜利进度条 C T S X 至少一条推进到顶,或拥有 12 区域,亦或拥有 25 人口。

胜利的玩家将给当局游戏的母星所在格命名,还有可能创造 wonder ,这会影响后面游戏的开局设定。同时还会根据这局游戏的胜利模式以及取得的科技情况创造出一张新的文明卡供后续游戏使用。

游戏以 36 张空白卡片开始。一共有 6 种需要打出卡片的行动,(EVOKE 和 PLAN 不需要行动卡),每种行动在6 张空白卡上画上角标 1-6 及行动花色。太阳表示 POWER ,月亮表示 SETTLE ,爱心表示 GROW ,骷髅表示 ADVANCE ,手掌表示 BATTLE ,鞋子表示 EXPAND 。这些花色表示卡片在手牌上的行动功能,也可以用来表示负面事件卡所触发的负面事件类别(规则书上有一张事件查阅表)。

数字主要用来生成随机数:比如在生成科技时可以抽一张卡片决定生成每个类别科技中的 6 种科技中的哪一个(规则书上有一张科技查阅表),生成随机地点时则抽两张组成一个 1-36 的随机数。


我初玩的时候搞错了一些规则细节,或是对一些规则有疑惑,反复查阅规则书才确定。

  • 开局的 12 个初始设定星球是从 36 张初始卡片中随机抽取的卡片随机生成的,而不是额外制作 12 张卡片。

  • 如果是多人游戏,需要保证每个玩家的母星上的初始科技数量相同。以最多科技的母星为准,其余玩家自己补齐科技数量。无论是星球卡还是科技卡,三个科技的花色(即科技类别)一定是随机生成的。这个随机性通过抽一张卡片看角标的花色决定。通常具体科技还需要再抽一张卡,通过角标数字随机选择该类别下的特定科技。

  • 每局游戏的 Setup 阶段,如果多个野生星球生成在同一格,野怪上限堆满 5 个即可,不需要外溢。但在游戏过程中由负面事件刷出来的新星球带来的野怪,放满格子 5 个上限后,额外的都需要执行外溢操作:即再抽一张卡,根据 1-6 的数字决定放在该格邻接的 6 格中的哪一格,从顶上面邻格逆时针数。放到版图外面的可以弃掉,如果新放置的格也慢了,需要以新的那格为基准重复这个操作,直到放完规定数量。放在中心黑洞的野怪暂时放在那里,直到所有负面事件执行外,下一个玩家开始前再弃掉。

  • 开始 START 阶段,玩家是补齐 5 张手牌,如果超过 5 张则不能抽牌但也不需要丢到 5 张。超过 10 张手牌则需要丢弃多余的牌。是随机丢牌,不可自选。

  • 殖民星球的 START 科技也可以在开始阶段触发且不必丢掉殖民星球。但在行动阶段如果要使用殖民星球的科技,则是一次性使用,即触发殖民星球上的科技就需要弃掉该星球。

  • 在 START 阶段触发的 Explorarion 科技可以移动一个 cube 。但它并不是 EXPAND 行为,所以不会触发 EXPAND 相关科技(比如 FTL),也无法获得 Wonder 。和 EXPAND 不同,它可以移动区域中唯一的一个 cube ,但是失去控制的区域中如果有殖民星球,需要从桌面弃掉。

  • 玩家不必执行完两个行动、甚至一个行动都不执行也可以。不做满两个行动在行动规划中非常普遍。两个行动必须不相同。

  • PLAN 行动会立刻结束行动阶段,即使它是第一个行动。所以不能利用 PLAN 制造出来的卡牌在同一回合再行动。

  • 行动的科技增益是可选发动的。同名的科技也可以叠加。母星和桌面的科技卡上提供的科技增益是无损的,但殖民星球和手上的完整科技卡提供的科技是一次性的,用完就需要弃掉。

  • 完成了三项科技的科技卡被称作完整科技卡,才可以在当前游戏中当手牌使用。不完整科技卡是不能当作手牌提供科技增益的。

  • SETTLE 行动必须满足全部条件才可以发动。这些条件包括,你必须控制想殖民的区域(至少有一个人口在那个格子);手上需要有这个格子对应的星球卡或该星球作为野生星球卡摆在桌面。手上没有对应格的星球卡时,想殖民必须没有任何其它星球卡才可以。这种情况下,手上有空白卡片必须用来创造一张新的星球卡用于殖民,只有没有空白卡时,才创造一张全新的星球卡。多人游戏时,创造新的星球卡的同时必须展示所有手牌以证明自己没有违反规则。如果殖民的星球卡是从手牌打出,记得在打出星球卡后立刻抽一张牌。新抽的牌如果是完整科技卡也可以立刻使用。如果星球卡是新创造的,或是版图上的,则不抽卡。

  • SETTLE 版图上的野生星球的会获得一个免费的 POWER 行动和一个免费的 ADVANCE 行动。所谓免费指不需要打出行动手牌,也不占用该回合的行动次数。这视为攻打野生星球的收益,该收益非常有价值,但它是可选的,你也可以选择不执行

  • SETTLE 的 Society 科技增益可以让玩家无视规则限制殖民一个星球。即不再受“手牌中没有其它可殖民星球”这条限制,所以玩家不必因此展示手牌。使用 Society 科技额外殖民的星球总是可以选择使用手上的空白卡或创造一张新卡。这个科技不可堆叠,每个行动永远只能且必须殖民一个星球。

  • SETTLE 的 Goverment 科技增益可以叠加,叠加时可以向一科技星球(星球卡创建时至少有一科技)添加两个科技,此时玩家先添加两个随机花色,然后可以圈出其中一个选择指定科技,而不需要随机选择。

  • GROW 的 Biology 科技增益必须向不同的格子加人口,叠加时也需要每个人口都放在不同格。如果所控区域太少,可能浪费掉这些增益。

  • 如果因为人口上限而一个人口也无法增加,GROW 行动无法发动。所以不能打出 GROW 卡不增加人口只为了获得相关科技增益。

  • 未完成的科技卡在手牌中没有额外功能。它只会在 ADVANCE 行动中被翻出并添加科技直到完成。如果 ADVANCE 时没有翻出空白卡或未完成的科技卡,则创造一张新科技卡。新创建的科技卡会立刻随机生成三个随机花色。玩家可以选择其中一个花色再随机出具体科技。在向未完成的科技卡上添加新科技时,如果卡上没有圈,玩家可以选择圈出一个花色自主选择科技,而不必随机。一张卡上如果圈过,则不可以再自主选择。

  • ADVANCE 的 Chemistry 科技增益可以重选一次随机抽卡,可以针对花色选择也可以针对数字选择。但一个 Chemistry 只能重选一次,这个科技可以叠加。

  • ADVANCE 的 Physics 科技增益只针对科技卡,不能针对星球卡。所以,无论 Physics 叠加与否,都最多向科技卡添加两条科技(因为科技卡一定会至少先生成一条)。当 Physics 叠加两次时(三次叠加没有意义),科技卡上的三条科技都可以由玩家自主选择(每一科技卡原本就可以有一条自由选择权,叠加 Physics 增加了一次选择权)。注意,花色一定是随机生成的。这个增益增加的是玩家对科技的选择权。

  • 只有在所有邻接格都没有敌人(野怪和其他玩家)时,才可以发动 BATTLE 行动的推进任意胜利条的功能。战斗默认是移除自己的人口,再移除敌人相同数量的人口。但可以选择移除自己 0 人口来仅仅发动对应增益。所以 BATTLE 行动永远都是可选的。

  • BATTLE 的 Military 科技增益新增的战场可以重叠,即可以从同一己方格攻打不同敌人格,也可以从多个己方格攻打同一敌人格。和 Defence 科技增益同时生效时,可以一并结算。

  • EXPAND 行动必须移动到空格或己方控制格,但目的地不可以超过 5 人口上限。永远不会在同一格中出现多个颜色的人口。移动必须在出发地保留至少一个人口。当永远 FTL 科技增益时,可以移动多格,途经的格不必是空格,也可以是中心黑洞。

  • EXPAND 行动移动到有 Wonder (过去游戏留下来的遗产)的格子,且该格为空时,可以通过弃掉对应花色的手牌发动 Wonder 能力,其威力为弃牌的角标数字。Wonder 只能通过 EXPAND 触发,不会因为开局母星坐在 Wonder 格触发。

  • BATTLE 的 spaceship 科技增益需要选择不同的目的地,多个叠加也需要保证每个目的地都不相同。

  • PLAN 行动制造新卡时,只有花色是自选的,数字还是随机的。PLAN 会结束所有行动。

  • 行动阶段后的 Payment 阶段可以用来消除之后 Challenge 阶段的负面事件数量。方法是打出和母星及科技卡上科技增益的花色。针对母星以及每张科技卡分别打出一张。如果卡片上有多个科技增益花色,任选其中一个即可。科技卡上未填上的增益对应的花色则不算。每抵消一张就可以减少一张事件卡,但事件卡最后至少会加一张。每次抵消一次事件,都可以所在卡片(母星或科技卡)上添加一个 upkeep 方块。每张卡上的方块上限为 3 ,不用掉就不再增加。但到达上限后,玩家依旧可以用手牌抵消事件,只不过不再增加方块。

  • 挑战阶段,一张张事件卡翻开。玩家可以用对应花色的手牌取消事件,也可以使用桌面方块取消,只需要方块所在卡片上有同样花色。还可以使用殖民星球取消,需要该星球上有对应花色的科技(不是星球卡的角标花色)。但使用殖民星球需要弃掉该星球卡。不可使用母星抵消事件卡。

  • 事件生效时,如果需要向版图添加野怪。这通常是增加随机方块事件,和增加野外星球事件(带有 5 方块)。增加的方块如果在目标格溢出,需要按规则随机加在四周。

  • 如果增加的方块所在格有玩家的方块,需要先一对一消除,即每个增加的野怪先抵消掉一个玩家方块。如果玩家因此失去一个区域,该区域对应的桌面星球也需要扔掉,同时扔掉牌上面的方块。如果母星因此移除,玩家可以把任意殖民星球作为新的母星。移除的母星会变成新的野外星球。如果玩家因此失去所有星球就会失败。在多人游戏中,失败的玩家所有人口都会弃掉,同时在哪些有人口的格放上一个野怪。

  • 游戏胜利条件在行动阶段达成时就立刻胜利,而不需要执行后续的挑战行动。在单人游戏中,除了满足常规的胜利条件外,还需要根据版图上的 Wonder 数量拥有对应数量的殖民星球(但最多 4 个)。这些殖民星球需要在不同的区格,且不在母星系。玩家胜利后应给当前母星所在格标注上名字,这个格子会在后续游戏中刷多一个野怪。玩家可以创建一张文明卡,文明卡的增益效果和胜利条件以及所拥有的科技相关,不是完全自由选择。

  • 不是每局胜利都会创造 Wonder 。需要玩家拥有至少 5 个同花色科技,才能以此花色创造 Wonder 。每个 Wonder 还需要和胜利模式组合。Wonder 以胜利玩家的母星位置标注在版图上,胜利模式和科技花色的组合以及 Wonder 地点不能在版图中重复。


这个游戏给我的启发很大。它有很多卡牌游戏和电子游戏的影子,但又非常独特。

不断制作卡牌的过程非常有趣,有十足的创造感。读规则书时我觉得我可能不会在玩的过程中给那些星球科技文明起名字,反正不影响游戏过程,留空也无所谓。但实际玩的时候,我的确会给三个半随机组合起来的完整科技卡起一个贴切的名称。因为创造一张完整的科技卡并不容易,我在玩的过程中就不断脑补这是一项怎样的科技,到可以起名的时候已经水到渠成了。

更别说胜利后创建文明卡。毕竟游戏的胜利来得颇为艰难。在失败多次后,脑海中已经呈现出一部太空歌剧,胜利的文明真的是踏着前人的遗产(那些创建出来的独有卡片)上成功。用心绘制一张文明卡真的是乐趣之一。我在 bgg 上看到有玩家精心绘制的带彩色头像的文明卡,心有戚戚。

游戏的平衡设计的非常好,有点难,但找到策略后系统也不是不可战胜的。关键是胜利策略会随着不断进行的游戏而动态变化:卡牌角标会因新卡的出现而改变概率分布,新的科技卡数量增加足以影响游戏策略,卡组里的星球科技会进化,星球在版图上的密度及分布也会变化…… 开局第一代策略和多个纪元的迭代后的策略可能完全不同,这让同一个战役(多局游戏的延展)的重玩价值很高。

用卡牌驱动随机性是一个亮点:以开始每种行动都是 6 张,均匀分布。但会因为星球卡打在桌面(从卡堆移除)而变化;更会因为创造新卡而变化。尤其是玩家可以通过 PLAN 主动创建特定花色卡片,这个创造过程也不是纯随机的,可以人为引导。负面事件的分布也会因此而收到影响。

用科技数量驱动负面事件数量是一个巧妙的设计。玩家获得胜利至少需要保有 6 个科技,即使在游戏后期纪元,也至少需要创造一个新科技,这会让游戏一定处于不断演变中。强力的桌面卡虽然一定程度的降低了游戏难度,但科技越多,每个回合潜在的负面事件也越多。以 3 科技开局的母星未必比单科技开局更容易,只是游戏策略不同而已。

每局游戏的科技必须创造出来(而不是打出过去游戏创造的科技牌)保证了游戏演变,也一定程度的平衡了游戏。即使过去的游戏创造出一张特别强力的科技,也不可以直接打在本局游戏的桌面。而只能做一次性消耗品使用。

一开始,负面事件的惩罚远高于单回合能获得的收益。在不太会玩的时候,往往三五回合就突然死亡了。看起来是脸黑导致的,但游戏建议玩家记录每局游戏的过程,一是形成一张波澜壮阔的银河历史,二是当玩家看到自己总是死于同一事件时有所反思,调整后续的游戏策略。

而战役的开局几乎都是白卡和低科技星球,一定程度的保护了新手玩家,平缓了游戏的学习曲线。边玩边做的模式让战役开局 setup 时间也不会太长,玩家也不会轻易放弃正常战役。

单局失败是很容易接受的,这是因为:单局时间很短,我单刷时最快 3 分钟一局,长局也很少超过 10 分钟。每局 setup 非常快。而游戏演化机制导致了玩家几乎不可能 undo 最近玩的一局,因为卡组已经永久的改变了。不光是新卡(因为只增加新卡的话,把新制造的卡片扔掉就可以 undeo ),还会在已有的卡牌上添加新的条目。

虽然我只玩了单人模式(并用新战役带朋友开了几局多人模式),但可以相像一个战役其实可以邀请其他玩家中途加入玩多人模式。多人模式采用协作还是对抗都可以,也可以混杂。协作和对抗会有不同的乐趣,同时都会进化战役本身。这在遗产类桌游中非常少见:大多数遗产类游戏都有一个预设的剧本和终局条件,大多推荐固定队伍来玩。但这个游戏没有终局胜利,只有不断创造的历史和不断演化的环境,玩家需要调整自己的策略玩下一局。

育儿的一些日常

作者 云风
2025年6月15日 18:52

以下从我最近两个月发的推文中整理。

可可

  1. 晚上可可读着书在床上睡着了。我没叫醒她,给她盖了被子就睡了。早上醒来时她跟我说,完蛋了,昨天晚上我没有洗澡。我说没关系,别被妈妈发现就好了。然后她蹑手蹑脚的偷偷起来换了身衣服,然后又躺回来睡觉。

  2. 可可的同学喊她联机 minecraft ,我在隔壁看书,听她们在微信上聊了半天硬是鸡对鸭讲。我过去跟她说,你把电脑屏幕拍个照片给她看不就好了。结果对面喊了句:你怎么玩国际版啊,我玩的是网易中国版,然后安慰可可说,你自己玩也是可以的。我想:到底谁是正版受害者?

  3. 可可最近在读的一本小说突然就找不到了,她怎么都想不起来在哪里。我花了许多时间引导她回忆,终于想起是周三的课外班落在隔壁班的教室里了。如果缺乏引导,她的记忆是绝对不可能打开的。周五家长会,我特地提前了 5 分钟到学校,和隔壁班主任解释了一番,在教室仔细搜寻,果然找到了。可可很开心。

  4. 可可最近读书挺认真的,问了好多问题。前几天问了好几个成语的意思,又比问了什么时候用——(破折号),还讨论了为什么小说里要写那么多她觉得并不精彩的情节。

  5. 可可二年级,我最近发现她数学是真的不行 :( 今天检查作业错了一道题,引导了半天才发现她对 100 以上的数字概念都没建立起来。比如知道 10 个 100 是 1000 ,但她觉得 20 个 100 是一万,而且讲了一个小时才纠正过来。果然,人类天生的感知就是对数的么?本福特定理诚不我欺。

  6. 我觉得文字阅读能力对人的一生非常重要,而现在的小孩娃很难自发练习了 :( 试过很多方法培养兴趣,还是很难。最近一个月试着强制每天半小时文字阅读。两个娃都还听话,虽然觉得是个负担,但也认了这个任务。但经过一段事件,感觉阅读能力真的有提高(从阅读速度判断)。

  7. 可可看了几部关于老鼠的小说后,已经开始跟同学说地球上最聪明的动物是老鼠,地球就是老鼠造的计算机了。

  8. 可可迷上和我一起玩 rimworld 。假期跟妈妈出去旅行,回到家第一件事就是让我打开电脑继续抓一只豚鼠当宠物。知道游戏可以通过存档回退时间后,她让我试了一下在婚礼上把除新娘之外的人全部杀掉。等她长大,我一定推荐她看一遍杀死比尔。

  9. 可可说看到短视频中说 switch 的卡带是苦的。我说你要不要试试,她挑了一张舔了一下说好苦啊。云豆说我也试试。过了一会,可可又换了一张再舔了一下,这下她相信每张 switch 卡带都非常苦了。

  10. 可可说,我们玩个游戏,我问你问题,你必须马上回答。我说好。可可问:你最喜欢哥哥还是我?看我没说话,她说,算了,这个问题不好,下一个问题……

  11. 应朋友邀请去扬州玩。在扬泰机场跟可可讲李白的诗,我说古时候送别朋友远行,可能就一辈子不会再见了。可可问,不能打电话吗?

云豆

  1. 云豆说,什么时候买 switch 2 啊。我说你又不喜欢马车,买它作甚。但还是下了单。第一天试了 switch 秘密展和马车,玩得很开心。周末他的一帮同学闻讯都来了我们家。但是摸了一下 switch 2 以后,又围在 pc 上玩《小飞船大冒险》去了。

  2. 云豆家长会上,班主任展示了一张同学自制的贺卡。粉色的封面上画着一颗爱心,写着“我喜欢你”。打开后,内面用透明胶贴满了一整面秘密麻麻的蚊子。看来广州的夏天蚊虫真多,难为孩子能攒这么多。

  3. 云豆很兴奋的告诉我,他在科学课上学到埃菲尔铁塔是古斯塔夫造的。他前段玩了 33 号远征队,古斯塔夫是他最喜欢的角色。我告诉他我对埃菲尔铁塔那一带的路很熟,因为我玩了鬼武者。

  4. 给云豆买了本《猫和少年魔笛手》,我自己先读了一遍,非常喜欢。不过我怀疑他可能不太看得懂。云豆最近主动看书了,《哈利波特》已经快把《凤凰社》读完了。前几年我给他读过前四本,这次是他自己主动从第一本开始读的。

  5. 云豆问我,100 以内哪个数的因数最多,它有多少个因数?我想到一个问题:取一个足够大的整 n ,比 n 小的整数中因数最多的数大约有多少个因数?云豆说他找到 100 以内因数最多的是 96 ,有 12 个因数。我说 72 也是 12 个,我问,你能不能证明没有更多的了?1000 以内最多因数的是 900 ,有 27 个(其实 840 的有 32 个因数更多)。我给他讲了应该怎么找到这个数,以及应该怎样快速计算因数的个数。他发现分解质因数有实际的用途,还是挺开心的。

  6. 云豆学校最近查视力,一边 5.0 一边 4.4 ;去年都是 5.0 。前同事介绍了个医生,我们就去看眼科了。配了 OK 镜,前两天佩戴颇费事,第三天开始就很顺手了。

  7. 买了一本《在数学的雨伞下》。我先读完觉得内容不错,然后在睡前给云豆读了三次共三个小时左右。出乎意料,接受度还不错。不过每次不能太长时间,小孩得慢慢来,时间长一点他就犯困。

  8. 云豆看了 switch 2 预告的直面会后非常开心,因为樱井政博又回来做卡比了。

  9. 和云豆把双影奇景通关了,然后在隐藏关死了几百次才打到第三小关。云豆强迫症犯了,一定要我陪他练习直到把隐藏关通关。

  10. 云豆同学来家里玩,我说你们打游戏水平都不错,不如一起玩双影奇境。玩了两关后,同学说没意思,我们还是玩蛋仔派对吧。

  11. 晚上给云豆讲了一晚上勾股定理,用的总统证法。娃还没开窍,累死他也累死我了。最后他终于自己想通了等量加等量还是等量;我一开始以为是公理所以没办法教,这个必须自己想明白。“三角形的内角和是 180 度” 这个可以有疑惑的定理却很快接受了,只因为老师在课堂上讲过。去年花了 8 个周末给云豆讲质数,质因数,公约数等等。虽然花了比我预想得多的时间,但我确定他最后是懂了。今年课堂上开始教了,他说很轻松。我看课堂速度比我去年教的快多了,如果靠老师教,估计要学个一知半解。

  12. 云豆拿了语文测验卷子回来,错了好多。我给他讲卷子发现他阅读能力真的是很差。三国演义的半白话自然是完全不懂的;而一篇白话的百草园,也是一半没看懂。很多书面语的词完全不明白意思。

  13. 云豆的好朋友过生日,我帮他选了个礼物 RG28XX 。

  14. 云豆一大早起来背 100 以内的质数表,课本上还有口诀,说是今天数学老师要抽查。我说不需要这么背的。你心里顺着数数,把个位是 1379 的挑出来,去掉乘法口诀里出现的数字比如 49 ,再检查一下是不是 3 的倍数。最后记住 9 字头的只有 97 就好了。他试了一次就开心的上学去了。

电梯的交互和调度

作者 云风
2025年6月9日 14:02

今天在网上和人闲扯,说到电梯的交互设计或许是有问题的。一般在楼宇的电梯区,会设置上下两个按钮,让乘客表达自己是要上行还是下行。如果在电梯区显示电梯当前所在楼层的话,就会有人理解为:上是指让电梯轿厢向上运行,下是指让其向下。一旦这样理解,就会输入错误的指令。

几乎每个有过在高层办公室上班经历的程序员都参与过电梯调度算法的讨论。看来,在饭点挤电梯是程序员们的共同记忆。(另一个永恒话题是怎样提高厕所的使用效率)我也不例外,20 多年里,我曾经反反复复和人讨论过这个问题。现在再也不用挤电梯了,似乎可以把过去考虑过的方案记录一下。

先说说现实存在过的方案:

大多数方案都是为了提高电梯的运营效率。要么为了节能,要么为了更快的满足乘客需求,要么为了提高高峰时的吞吐量。

大部分高层建筑都把多部电梯按楼层分区。有些电梯只服务低层,有些服务高层。如果楼层更多时可能还会分出更多区间。对于超高层建筑,也有把顶楼超高区单独分割出来,需要转电梯的。

这个设计显然是因为对于高层建筑,电梯的需求按楼层分布是金字塔型的。乘客永远都需要从地面进入,越往上,乘客越少,而路程越长。乘客的目的地却是接近均匀分布的,如果让乘客随机进入任意轿厢,最终电梯会在更多楼层停留开门,将乘客预先分组就显得很有必要。

另外,为了解决繁忙时段的吞吐量问题,电梯也可能按单双层分组。这样可以把一部分运力转嫁到楼梯上,减少每部电梯的停留时间。类似的方案还有针对乘客类型设置专用电梯,比如让饭点运输食物的乘客走专用梯,而减少乘客使用电梯的需求量;让领导们使用专门电梯,提高他们的幸福指数以获得重要工作上的效能增益,等等。

也有一些办公楼会让乘客预先输入自己的目的地,而不是简单的选择上行还是下行。这样,系统理论上可以统筹安排。我也使用过这样的系统,效果嘛,一言难尽。只能说理想很丰满,现实很骨感。电梯公司想乘着软件系统升级多赚点钱无可厚非,但复杂系统就是这样:很难把它实现得正确。


回到文章开头的话题,我的观点是,与其做一个交互更复杂的系统妄想提高效率,还不如进一步简化它。

其实,电梯的外部控制按钮或许并不需要上下两个?只要一个召唤按钮就够了。

首先,这样的交互设计是没有歧义的:我需要使用电梯,就召唤它过来。

其次,用上下行来对乘客预分类过于粗糙,实际中对效率的提升非常有限。

如果建筑只有一部电梯,看起来对乘客分类的意义最少:反正乘客都必须乘坐这唯一一部电梯去目的地的,即使电梯目前运行方向相反,提前进轿厢的区别也仅仅是在里面等待还是在外面等待。

有同学说,不对啊,假设电梯目前从 1 楼向上运行到 10 楼顶楼,5 楼的人想下去,按了召唤按钮,电梯就可能在上行过程中做无谓的停留。如果电梯按钮分开上下,需求就明确了,电梯只会上去后下行时才会停下来。

我的观点是,其实电梯在上行时停下来,乘客就可以进去了。这样电梯之后下来时就不用再次在 5 楼停下来。无论是电梯运行时间,还是乘客抵达 1 楼的时间,差别都微乎其微。

而且就我的实际经历:在饭点想乘电梯下楼的话,往往是只要电梯开门了就进去,哪管它上行还是下行。你不进去等下就进不去了。稍低楼层的人更多的是反向坐电梯,不然就可能要等到餐厅快打烊了才吃得上饭:因为你不反向乘电梯的话,电梯下行的第一站永远是最高层,比你楼层高的乘客会优先使用。电梯的设计运力难以满足高峰期的需求。

在这种使用场景下,略低楼层的乘客往往上下两个按钮都会按下,在电梯上行时就先进入轿厢,而当电梯折返下行路过同一楼层时,电梯再次开门,外面却已经没有乘客了。效率反而降低了。如果电梯只设一个召唤按钮,这个问题就可以回避掉。

ps. 真要在高峰期保证公平的话,电梯需要设置成:单趟只停一个楼层,然后循环这个目的地。例如,一部高层电梯可以依次循环停 30, 29, 28, .... ,每一趟只在目的地停一次。这个运行模式可以在电梯不满载时自动取消,或是按高峰时间段固定开启。可惜我工作过的办公楼还没有见过电梯系统能设置成这种公平模式的。

这种单按钮召唤的设计,只要算法合理也并不比上下两按钮的低效。上下两按钮只是预先把乘客分开上行组和下行组,避免只有一组乘客时电梯反向停留(例如在上行阶段为下行乘客开门)。单按钮系统可以在载荷超过阈值时拒绝响应外部召唤请求,在完全已进入轿厢的乘客都送达目的地后,再根据外部召唤情况跑下一趟。这样就可以做到:延迟处理外部请求队列,以延长乘客外部等待时间为代价,增加单趟满足的乘客数量,从而提高整体的运行效率。

那么,如果电梯系统有多部电梯时怎样处理呢?

我觉得也可以不用上下行按钮预分类乘客。而是将电梯本身分为上行开门和下行开门。和高低层分类一样,乘客应该自行去上行区和下行区召唤电梯。鉴于除了地面的乘客趋向于上行外,其实绝大多数楼中乘客使用电梯都是下行的。多部电梯的系统只要保留一部梯为上行开门就够了。而且,即使你需要下行,其实也可以进入这部梯,它或许并不慢。因为一旦反向抵达有乘客召唤的最上一层,之后它下到一楼是直达的(下行不开门)。

一个简单的 A star 寻路算法实现

作者 云风
2025年5月14日 12:05

我需要一个接口简单的寻路模块,所以今天写了一个 。其实之前也写过很多版本,在我上传代码时就发现我自己的 github 账号下早有同名仓库。不过,之前的版本的接口设计不太满意,直接删掉了,用这次的新版本复用老的仓库名字。

我希望达到的目标是,C 接口简单易用,且和地图本身的数据结构无关,只提供寻路功能。这样容易拓展到不同应用场景。

数据结构简单,内存开销固定,在算法执行过程中不额外分配内存。这可以方便的在多线程环境运行。

我不需要处理特别复杂和规模巨大的地图,那种场景应该额外做一些预处理。但在起点和终点的路线结果不长时(即使在大规模地图上),应该有较好的性能。

原始的 A star 算法实现最为简单,在大多数情况下有不错的表现,所以我选择了它。我知道算法可以有很多改进方法,但我觉得代码简单最为重要。

通常 A star 算法依赖一个优先队列,但我没有选择使用诸如平衡二叉树等复杂结构来实现它,而使用了最简单的单向链表。因为这样可以轻松的把全部数据全部塞在一块平坦内存中。

基础数据结构是一个用数组实现的闭散列 hash 表,使用者来决定使用多大的数组,通常使用预期路径长度的平方大小会比较合适。为了减少每次寻路的初始化成本,使用了一个 version 值表示每个 slot 的初始状态,每次调用寻路,都会把 version 递增( O(1) 操作),这样就可以让整个 hash 表的所有 slot 复位。

寻路过程中每个尝试的节点都会加入 hash 表中,在 hash 表使用率超过一半就会中止算法,防止性能恶化。但接口在这种情况下依然会返回已经找到的离目标最近的中途点。

在不复杂的大规模地图上,通常可以通过多次调用找到完整路径。但依然建议针对大地图做更高层次的预处理。在 Youtube 上有一个 Rimworld 作者讲解 Rimworld 中区域分割系统的视频值得一看,搜索 "RimWorld Technology - Region System" 可以找到。

A star 工作中的待展开节点集是用单向链表的形式串起了 hash 表中的 slot ,而没有使用额外的优先队列结构。虽然单向链表的插入操作是 O(n) 的,但我猜想在大部分场景中,这个 n 并不算大。尤其是估价函数理想工作状态下(朝着目标直线移动),新插入的节点都是在链表一端附近的。这个猜想需要足够多的测试数据验证。

为了调试算法工作中的内部状态,模块提供了一个函数可以输出整个 hash 表的当前状态图(仅限于每个 slot 的 gscore ,即离起点的路程)。合理使用这张图,可以把算法的内部状态可视化表现。test 中使用 ascii 字符展示,但用灰度图输出图像效果会更好。


代码刚写好,尚未充分测试。但我觉得接口设计还算通用,应该会有人愿意使用。期待有更多人使用而让代码的质量提升。

卡牌构筑类桌游核心规则之七

作者 云风
2025年5月8日 14:48

纯单人游戏在桌面游戏中不太多见,但我很喜欢这种。毕竟,找人一起玩桌游太不容易,虽然多人协作桌游总可以一个人操控多方进行 solo ,但终究不是为单人游戏设计的。今天介绍的两款单人卡牌游戏,我没买到实体版,都只是在桌游模拟器上玩过几盘。

第一款是 Legacy of Yu (2023) 大禹治水。老实说,这不是一款卡牌“构筑”游戏。虽然在游戏过程中玩家还是需要从市场列“购买”新卡片,但游戏过程并不是围绕构筑进行的。这些卡牌更像是消耗品。

游戏中的工人卡有三种用法:

  1. 打出后获得卡片上标注的资源,然后进入弃牌堆。

  2. 销毁一张工人卡,获得卡片上额外标注的一次性资源,卡片将移出游戏。这样获得的资源一定比前一种方法获得的多。

  3. 把卡牌(常驻)押在版图中已经盖好的房子上,此后的回合每回合获得持续资源奖励。

和一般的卡牌构筑游戏不同,卡堆不是越少越好。一般的卡牌构筑游戏,精简卡堆总是好的,因为这样可以加快卡堆循环,能更快抽到自己需要的强力卡。而这个游戏中,当抽牌堆耗尽,游戏进程就会向前推进。一旦准备不足,推进游戏会加快失败进程。虽然,游戏过程中,除非迫不得已,都不要销毁工人卡获得额外资源。

游戏中有砖头、木头、粮食、货币(贝壳)四种资源,以及白色劳工、红色战士、黄色弓箭手、黑色骑士、蓝色枪兵五种工人。

资源可以用来做建设:砖头+木头+劳工=农场(三个待建),砖头+3x木头+劳工=前哨(四个待建),3x砖头+木头+劳工=房屋(四个待建),运河。

农场效果是固定的,为后面的轮次每回合增加固定产能。三个农场分别对应粮食、劳工、其它任意工人;前哨可以让四种特殊工人和白色劳工相互替代;房屋的触发效果是随机的,标注在房屋卡片背面,大多数是触发说明书上的事件。建成房屋还可以为工人提供工作地(将工人换成对应资源)以及常驻资源产地(减少卡组中的工人卡换取持续产能)。

游戏需要玩家以不断增加的成本修建六段运河。成本会逐步递增,一二段需要两个劳工及两个贝壳;三四段需要三个劳工及两个贝壳;五六段需要两个劳工两个指定颜色工人及三个贝壳。每段运河修建成功后,可获得一次性奖励并摧毁部分抽牌堆中的工人卡,以及触发事件。并可以为之后的游戏回合带来一些贸易选项:

贸易选项是固定的:

  1. 两个贝壳换一个粮食

  2. 一张弃牌换两个贝壳

  3. 劳工及粮食转换为任意工人

  4. 四个贝壳换一个劳工

  5. 两个粮食及两个贝壳增加一张工人卡

  6. 砖头或木头换一个贝壳

玩家需要在修完六段运河后存活到回合结束才能赢得游戏。

在游戏版图上方由预备工人卡和蛮族卡共享市场列。蛮族卡越多,可选择的工人卡就越少。一旦市场列全部挤满蛮族卡,游戏就会失败。

市场列的最左端位置上的工人卡总是免费的。玩家可以选择拿取放在弃牌堆,也可以直接销毁获得一次性资源。而蛮族卡会随着修建运河的进程逐步进入市场列。未消灭的蛮族,在每个回合结束会收取贡品,通常贡品可以用销毁一张工人卡抵消,但如果工人卡被销毁光也会导致游戏失败。击败蛮族需要支付卡片上标注的指定种类的工人,击败蛮族后会获得卡片上标注的一次性奖励。

这个游戏有传承机制,熟悉基础规则后,可以根据说明书逐步解锁新的玩法:游戏难度会随着游戏进程慢慢加强,并引入一些新的游戏机制。因为根据单局游戏失败或成功,导向不同的游戏进程,难度也是动态调节的。

这个游戏不是很热门,可能是因为它只能单人游玩,在国内很难买到实体版。但这个游戏我非常喜欢,桌游模拟器上有汉化过的电子版。


Kingdom Legacy: Feudal Kingdom (2024) 是一个较新的游戏。它和很多传承类游戏一样,几乎只能玩一次。因为游戏过程中会涂改卡牌,这个过程是不可逆的。而且卡堆一开始在包装内的次序是设计过的,一旦打乱还原比较麻烦。后期的一些卡片一旦被巨头,也会失去一些探索未知的乐趣。

可能是因为每开一局都需要新买一盒游戏的远古,这个游戏各处都缺货,不太好买到。好在其官网有所有卡片的电子版本,可以方便做研究。

游戏规则简单有趣,整个游戏过程是升级卡牌。大部分卡片有四个状态:两面每面上下两端。卡牌升级指支付一些资源,让卡片旋转到另一端或翻面(根据卡片上的指示),同时结束当前回合;卡片上也可能有一些标注的效果让卡片状态变化。资源不使用额外指示物,而是弃掉当前的卡片获得(同样标注在卡面),资源必须立刻使用,无法保留到下一回合。

这个游戏的“构筑”过程颇有新意。它没有主动挑选购买新卡的环节,但一旦抽牌堆为空,就会结束一大轮,会从卡堆中新补充两张新卡(以设计过的次序,没有随机元素)。而玩家的主动构筑在于对已有卡片的变化(升级)。

每一个回合,抽四张手牌,用其中三张的资源换取一张卡的升级。在玩的过程中,如果当前回合资源不足以升级卡片,可以选择增加两张新卡继续当前回合;也可以选择 pass ,放弃当前所有手牌,重新补四张。游戏不会失败,不管怎么玩,游戏都在抽到第 70 号卡后结束,并结算得分。玩家可以以得分多少评价自己玩的成绩。游戏包装内不只 70 张卡片,超过编号 70 的卡片会在游戏进程中根据卡片上描述的事件选择性加入游戏。

游戏中有六种资源:金币、木材、石头、金属、剑、货物。它们对应了不同卡片的升级需求。通过卡牌升级,把牌组改造为更有效的得分引擎是这个游戏的核心玩点。部分卡片是永久卡,可以在游戏过程中生效永久驻留在桌面提供对应功能。有些卡片会被永久销毁,如前所述,一盒游戏只能玩一次,所以一旦一张卡片被销毁就再也用不到了。按游戏规则的官方说法,你可以把已销毁的卡片擦屁股或是点火。

为了方便初次游戏熟悉规则,在翻开第 23 号卡片之前,可以反复重玩,玩家可以不断的刷开局;直到 23 号卡片之后,游戏才变得不可逆。而在游戏后期,玩家牌堆会越来越大(因为每个大轮次,即抽完牌堆后后会加入两张新卡),这时就引入了支线任务 ,在游戏术语中叫做扩展(expansions)。触发支线时,需要清洗(销毁)一张桌面的永久卡,并执行一次 purge 12 动作。即洗掉牌堆,抽出 12 张卡,选一张保留,并销毁另外 11 张卡。但 purge 的这 11 张卡上的分数可以保留下来,记录在支线得分中。支线会让牌堆维持在一个较小的规模。

每个支线都会持续 4 轮(对应卡片的四个状态),每轮旋转或反转支线卡推进任务。支线的每个状态都会有一个当轮生效的效果。游戏的基础包中有三个内置支线卡,额外的扩展包增加了许多任务(以及额外的卡片)。一局游戏可以最多触发 10 个支线。


8 月 11 日补充:

Kingdom Legacy: Feudal Kingdom 的卡片翻转机制我是第一次玩到,感觉挺有趣。但我最近又玩了一款老一点的游戏 Palm Island (2018) 也是同样的机制,玩起来也颇为有趣。

不过棕榈岛没有遗产机制,只有一些有限的成就卡(勉强也能算遗产)。它的资源储存和消耗方法比较独特,最有趣的一点是:虽然是一款桌面游戏,它被设计成没有桌面也能玩,只需要把握在手中即可,并不需要打在桌面上。

对数和自然对数的底

作者 云风
2025年4月10日 22:52

最近读了一本书:《数学的雨伞下》。阅读体验非常好,这本书用浅显的语言,科普了许多深刻的道理。这本书所介绍的知识结构比较类似我挺喜欢的另一本《从一到无穷大》,但讲解更为细致一些,以至于如果事先明白这些知识,甚至会觉得有些冗长。但细细品味,会觉得理解能更深一层。

我在通读完一遍之后,这几天带着儿子精读。重读第一章中“对数之桥”一节时,我思考了一个问题:当年纳皮尔 Napier 到底出于什么动机制作一张高精度对数表,他制表的计算思路是怎样的。书中并没有答案,所以我又在互联网上翻看了当年 Napier 原著 Mirifici Logarithmorum Canonis Descriptio 的介绍,感觉收获颇丰。

制作对数表的直接原因当然是为了简化大数乘除法的计算。对数概念的提出在幂概念建立之前,而现在的数学教学中,一般却是从幂自然推导到对数的。似乎后者才是自然而然的。这应该是因为,古人研究数学,最初是为了解决现实中的问题。所以,乘法必须有对应的几何意义。比如,计算正方形面积需要把计算边长的平方;立方体的体积需要计算边长的立方。更高次的幂却难有对应的几何意义,有理数幂则更为抽象。

现实中,也很难碰到极大的数字,超乎寻常的精度需求也很小,除了天文学。

人无法以上帝视角在宇宙空间中做测量,只能以地球为基点。所以,天文尺度的计算都依赖三角学。把天文(以及地理这种地球尺度的)数字问题化为三角函数,然后再加以计算。比如,测量地球到太阳的距离、地球到月球的距离、地球的直径都是这样。因为这些尺度都非常大,如果计算精度不够,就容易失之毫厘,差之千里。

为了测算太阳系内天体的距离,可以在地球表面找两个尽可能远的点(最多相距地球的直径),观察天体,记录下天体在视野中的角度。这样,地球表面的两个端点和被观察的天体,就构成了一个三角形。三角形的底边就是两地的距离,而顶角则可以对比两地观测的结果得到。这就是三角视差法。可想而知,对于太阳系内的天体,这个视察角度非常小,需要极高的观测精度和计算精度才能计算出距离(远大于地球直径)。

甚至,这个方法可以运用到测量附近恒星到地球的距离。这几乎是人类利用三角法能测算的最大尺度。在地球表面找两个点已经不够了,因为那最长不超过地球的直径。更长的标尺只有地球绕太阳的轨道:在一年中隔半年做一次观察,这两个观测点在宇宙空间中就隔了地球和太阳距离的两倍长,这总该够长了吧?其实不然。在这个尺度上,古人依然观察不到星星的位置相隔半年的星图中有所不同。这也是为什么日心说提出后,不光是神学家不接受,连天文学家(比如第谷)也不接受。

如果地球围着太阳转,而地球距离太阳如此之远,那么就算是恒星离得再远,地球位于太阳两侧时,总能观测到某些明亮(离我们相对较近)的星星位置有些许偏差吧?人类难以相像太阳系外的宇宙如此空旷。事实是,太阳以外的恒星离我们真的太远了,即使以地日这种天文距离为底边,和附近的恒星形成的等腰三角形的顶角也不到一秒。过去的测量工具的精度是完全不够用的。直到 19 世纪中叶(哥白尼死后 200 多年)人类才真正观测到天鹅座61/贝塞尔星 有 0.3 个秒视差,从而估算出离地球大约 10 光年左右。

测量精度是一方面,计算精度也很重要。在三角公式里算几个乘法,若是通过对数方法转换为加减法计算,而精度不够的话,恐怕结果会差上一个数量级。

纳皮尔在没有幂概念的基础上就发展出了对数概念,靠的还是寻求其几何意义。他的灵感来源并不是幂运算,而是三角公式。三角和差公式中,角度相加被转换为三角函数的乘法运算,这提示着,乘法和加法之间可以相互转换。纳皮尔的对数表也并不是现在意义的列一系列数字,逐个列出它们的对数。而是给出角度的三角函数值的对数。它可以看成是当时已存在的三角函数表的拓展。这也是为什么,纳皮尔的表只有 90 * 60 = 5400 项(对应四分之一圆周在分精度下的所有角度值),但数字精度却有小数点后 7 位。因为当时最精确的三角函数表是 7 位精度。

在没有计算机的年代,计算对数必须查对数表。那么最初的对数表怎么得到的呢?如果是按幂函数的逆去计算,那就涉及高次开方,人肉计算显然是不可能的。而且当时,还并没有发现对数和幂的互逆关系(那要等到 100 年后的欧拉),甚至连幂的概念都没有。

《数学的雨伞下》这本书为了让读者更容易理解对数表,举例子使用的是以 2 为底的对数。对数列是一个自然(等差)数列:1,2,3,4,5... ;真数列是 2, 4, 8, 16, 32 .... 这样一个等比数列。但实际这样制作对数表会难以实用,因为真数数列膨胀的太快了。如果要实用,最好真数数列的间隔不要太大。如果间隔太大,在利用它做乘法运算的时候,很多数字会偏差很大。

把对数用于快速计算乘法,选用怎样的底并不重要。当等差数列的差距为 0.00000001 时(因为当时的三角函数表有 7 位精度),等比数列的差值选为 1.0000001 或 0.9999999 最方便计算。因为这样,列出等比数列时,就不需要连续计算乘法,而只需要移位相加即可。一个十进制数乘以 1.0000001 只需要把这个移动 7 位的小数点,再加上原数即可。如果等比数列的公比为 1.0000001 ,其实是给对数表选择了一个以 (1+1/n)^n (n = 10^7) 的底。当然,这是现代数学的看法,在纳皮尔的时代,还没有发展出底这个概念。

纳皮尔研究的是三角对数,真数范围在 0 到 1 之间。当时的人并没有完整的小数和数级的概念。过去研究圆,使用的是一个超大的(10^7)的半径而不是今天流行的 1 。因为这样,三角函数才能近似为整数(对于 7 位精度,使用10^7 的圆半径,相当于比今天的三角函数放大了一千万倍)。btw, 纳皮尔在制作对数表的过程中,发明了小数点,用来保留计算过程中的精度。

他先构造了一个等比数列,再通过几何定义去计算其对数对应的等差数列。从现代观念看,纳皮尔选择的底约为 0.9999999^1000000 ,非常接近 1/e 。

在今天来看,如果我们想制作一张好用的对数表,真数列自然是越密越好。如果我们把 n 取无穷大,让 1/n 足够小,(1+1/n)^n 的极限即为欧拉数 e 。我想,这也是为什么欧拉数 e 被称为自然对数的底。纳皮尔的时代,无法通过“视对数函数为幂函数的逆函数”来建立这种直观的认识,人类深刻认识 e,要到百年后的欧拉。

关于纳皮尔如何制作对数表的,他自己写过构造方法一书。300 年后的 1914 年 EW Hobson 写了 John Napier and the invention of logarithms, 1614 纪念纳皮尔,详细讨论了纳皮尔原著中的方法。这篇文章在网上可以找到中文翻译。另可以参考这一篇文章

纳皮尔在计算过程中,充分考虑了计算的误差区间,严格保证他计算的对数表满足 7 位精度。他首先计算了 0.9999999 的 0 到 100 次方,然后计算 0.99999999^100 = 0.99999 的 0 到 50 次方。虽然计算这个等比数列只需要把前一个数字在十进制上移位并计算减法,这个计算工作并不难(并不需要算乘法),但纳皮尔在这一步把最后一项算错了:本应该是 0.999500122480 ,而他计算成了 0.9995001222927 。这个 bug 导致了使用最终的对数表会产生微小的误差(影响最后一位数字),纳皮尔自己觉得这个误差是三角函数表不精确导致的,并建议用 8 位精度重制三角函数表。

卡牌构筑类桌游核心规则之六

作者 云风
2025年4月8日 21:10

这次介绍两款在国内人气不高的卡牌构筑类桌游。游戏都还不错,可能是因为没有中文版,所以身边没见什么朋友玩。

首先是 XenoShyft 。它的最初版全名为 XenoShyft: Onslaught (2015) ,后来又出了一个可以独立玩的扩展 XenoShyft: Dreadmire (2017) 。

故事背景有点像星河舰队:由人类军士抵抗虫子大军。简单说,这是一个塔防游戏:游戏分为三个波次,每个波次三轮,一共要面对九轮虫群的冲锋。

游戏中有四类卡片:部队、敌人、物品、矿物,另有一组表示玩家所属部门的能力卡,每局游戏每个玩家可以分到一张,按卡片上所述获得能力

矿物就是游戏中的货币,用来在市场购买部队卡和物品卡。敌人卡分为三组,对应到三次波次,洗乱后形成系统堆。玩家需要在每轮击败一定数量的敌人,撑过三个波次就可以取得游戏胜利。

玩家基础起始牌组 10 张,4 张最低级的士兵和 6 张一费的矿物。根据玩家的部门,还会得到最多 2 张部门所属的特殊卡。

市场由部队卡和物品卡构成,其中部队卡是固定的,分三个波次逐步开放购买。物品卡一共 24 种(基础版),但同时只会有 9 种出现在市场上。玩家部门可能强制某种物品一定出现在市场上,其它位置则是每局游戏随机的。在游戏过程中,当一种物品全部买空后,会在市场中随机补充一堆新的物品卡。普通敌人卡按波次分为三组洗乱,然后根据波次再从 6 张 boss 随机分配到三个波次中。

每个轮次,玩家先抽牌将手牌补齐到 6 张,然后打出所有的矿物卡,并根据波次额外获得 1-3 费,然后用这些费用从市场购买新卡片,花不完的费用不保留。新购得的卡片直接进入手牌(而不是弃牌堆)。这是一个合作游戏,所以玩家可以商量后再决定各自的购买决策。

然后,玩家把手牌部署到战区。每个玩家把部队卡排成一行(最多四个位置),物品中的装备可以叠在部队卡上增强单位的能力。玩家可以给队友的部队卡加装备(但不可以把自己的部队卡部署在队友战区)。部署环节玩家之间可以商量,同时进行。

之后进入战斗环节。这个环节是一个玩家一个玩家逐个结算。翻开敌人队列上的敌人卡(在部署环节是不可见的)、在敌人卡片翻开时可能有一次性能力,发动该能力、然后(所有)玩家都有一次机会打出手牌中的物品卡或使用部署在战场上的卡片能力。之后,双方队列顶部的两张卡片结算战斗结果。卡片只有攻击和 HP 两个数值,分别将自己的 HP 减去对手的攻击点。一旦有一方(或双方)的 HP 减到 0 ,战斗结束,把战斗队列卡片前移,重复这个过程。直到一方队列为空。

如果己方部队全灭,每场战斗的反应阶段(每个玩家都可以打出一张手牌或使用战斗卡片能力)依然有效,但改由基地承受虫子的攻击。基地的 HP 为所有玩家共享,总数为玩家人数乘 15 。可以认为基地的攻击无限大,在承受攻击后,一定可以消灭敌人。一旦基地 HP 降为 0 ,所有玩家同时输掉游戏。

游戏中的死亡效果有两种,毁掉(burning)和弃掉(discarding)。毁掉一张卡指把这张卡片退回市场(如果市场上还有同类卡)或移出游戏(市场上没有对应位置),而弃掉一张卡指放去玩家的弃牌堆。

通常,敌人卡片效果一次只会结算一张(即当前战斗的卡片)。但有些卡片效果会对场上敌人队列中尚未翻开的卡片造成伤害。这种情况需要先将所涉及的敌方卡片都翻过来,并全部结算卡片出场能力。对于需要同时结算多张敌人卡片出场能力时,玩家可以讨论执行次序。

如果对这款游戏有兴趣,又找不到人玩的话,可以试试它的电子版,在 steam 上就有。不过看评论,据说电子版 bug 有点多。


另一个游戏是 G.I. JOE Deck-Building Game (2021) 。G.I. JOE 特种部队是孩之宝(也就是变形金刚品牌的拥有者)旗下的一个品牌,除了玩具,有衍生的漫画、电影和动画片。这个桌游也是这个玩具品牌的衍生品。我认为这个 DBG 里的某些设定(不同的游戏剧本、同一剧本中不断推进的故事任务、队员的多种技能)也影响了星际孤儿那个电子游戏。

游戏有很多剧本、以及若干扩展。不同的剧本在规则细节上有所不同(这一点和星际孤儿很相像),这里只减少核心共通的规则。

这是个多人协作游戏。当然,只要是协作游戏,就一定可以单人玩,只需要你轮流扮演不同角色的玩家即可。每个玩家一开始有一张特殊的领袖卡,然后配上 9 张固定的初始牌组成了起始卡组。每个回合摸 5 张卡,用不完的卡会弃掉,不能保留到下一回合使用。每张领袖卡都对应了一个升级版本,可以在游戏进程中购买替换。

市场由一组卡片洗乱,随机抽出 6 张构成。每当玩家购买一张卡,就会补充一张新卡。但如果卡堆耗尽尚未结束游戏,游戏失败。在游戏过程中,可能有敌对卡片出现,会盖掉市场中的卡。玩家需要解决掉敌人,否则盖掉的卡片无法购买。如果 6 张市场卡片都被盖掉也会导致游戏失败。

当每个玩家执行完一轮行动,即为一大轮游戏。在每大轮开始,都会推进一个全局的威胁指示条。一旦威胁指数上升到某一程度,就会发生一些固定事件。维护指数走到头会导致游戏失败。

游戏故事由三幕构成,每幕随机选取两张对应的故事任务卡和一张固定的终局局故事卡,一共 9 张故事任务卡构成了整局游戏。永远有一个故事任务呈现在场景中,它有可能触发一个回合效果,需要在每个玩家回合开始时结算。

每一幕开始都会洗混所有的系统事件卡堆(包括之前解决完弃掉的事件卡),故事卡和威胁进度条会触发这些事件。这些事件会给玩家增加一些负面效果,或是在场上增加一些任务让玩家解决。

游戏任务分两种:团队任务和支线任务。故事卡一定是团队任务,事件产生的 boss 卡也是团队任务。团队任务可以在当前玩家决定去进行任务时,其他玩家提供协作;而支线任务只能由当前玩家独立完成。任务有地形、难度、技能需求、持续效果等元素构成。

地形指玩家开启任务需要使用怎样的载具,分陆海空三类。技能要求则限制了玩家可以派出的队员。难度数字决定了玩家最终需要在此技能上获得多少点才能完成任务。持续效果则会在该任务完成前,对玩家造成的负面效果。

开启一个任务需要玩家从机库派出一个对应地形的载具以及至少一个队员(从手牌打出)。该载具是在玩家回合开始时从手牌打在机库中的,VAMP 作为默认载具总可以保证一个回合使用一次。高级载具可以从市场购买。对于团队任务,所有玩家都可以协商派出队员,但队员总数不能超过载具的容量。

任务上标注了所需技能,派出的队员卡如果有符合的技能,则可以把技能点加到任务中。如果技能不匹配,也可以视为一个通用技能。任务卡最多要求两种技能,如果是 & 标记,则表示可以只要符合两种技能中的任意一种都可以生效;如果是 or 标记,则需要当前玩家选择其中一种技能,所有队员都需要匹配这种技能。

最终参与任务的技能总数决定了最终可以用几个六面骰。每个六面骰有三个零点,两个一点,一个两点;扔出对应数量的骰子,把最终点数相加,如果大于等于任务的难度值,则任务成功并或许成功奖励,否则任务失败。对于故事任务,失败需承受任务卡上的失败惩罚,并结束任务;对于其它任务,失败会让任务继续保留在场上。

有一类叫做 Precision Strikes 的任务,在翻出时需玩家讨论后决定放在谁的面前,变成它代做的支线任务。每个玩家最多只能放两张 Precision Strikes 在面前,到他的行动回合,必须先处理掉 Precision Strikes 任务。

在玩家做完所有想做的任务后,剩余的手牌可以作为够买新卡的费用。每张卡都标有一个自身的价格,以及一个在购买阶段可以当成几点费用使用。没有用完的费用不会积累到下个回合。购买的载具卡会在购买后直接进入机库,供后续回合所有玩家使用。其它卡片则放在抽牌堆顶。

整个回合结束后,弃掉所有手牌,以及回合中使用过的卡牌以及载具,重新抽五张。

玩家可以共建一个基地。这个基地由五部分构成,在游戏过程中逐步升级,升级也时通过购买完成的。在游戏过程中,以升级的部分也可能被摧毁或重建。这五个部件如下:

Repair Bay 会让任务中使用的载具都放在该处而不是弃牌堆。在回合结束(或被摧毁),Repair Bay 中的载具都会回到机库。这样,载具的利用率会大大提升。

Stockade 建成后,击败的 boss 卡会进入这里而不会重复进入游戏。

Battlestation 可以在团队任务中重掷一个骰子。

Laser Cannon 可以在支线任务中增加一个骰子。

Command Room 把手牌增加到 6 张。

卡牌构筑类桌游核心规则之五

作者 云风
2025年3月12日 09:30

最近的兴趣重心转移,没怎么研究桌游。不过前几个月的笔记还有一点,今天继续整理一下。继续谈谈 PvE 向的卡牌构筑类桌游。

Aeon's End (2016) 末日决战是一款偏传统卡牌对战规则的游戏:一开始手牌中只有水晶 (gem) 和基础法术 (spell) 卡。游戏过程中,在一个固定市场(9 种)用水晶购买更强力的卡片升级自己的卡组。游戏的目标是合作(或 solo)击败 boss 或 boss 的仆从。如果被敌人攻击太多次,自己的 HP 减到 0 就失败了。

法术卡其实更像是炉石/万智牌中的玩家仆从,只不过是一次性消耗品。需要先部署在桌面,然后才可以攻击。而玩家卡组内的第三种遗物卡(relic)则更像是一般意义的法术:可以即时产生效果。

打出法术卡或遗物卡是没有直接费用的,水晶主要用来支付从市场购买新卡的费用。另外,法术卡要生效,需要桌面有空闲的位置(叫做 breach 裂隙)。所以即使手牌中的很多强力法术卡,也不能无限制的一次摆出来。

裂隙需要花费水晶费用打开才能使用(摆放法术)。打开 (open) 裂隙在一局游戏中是一次性的,一旦裂隙开启,就可以一直使用(放置法术)。但开启费用较高,也可以 focus 一个关着的裂隙一次性使用。focus 费用较低,但下次使用还需要再次支付水晶 focus 。focus 同一个裂隙 4 次后会自动开启。在开启的裂隙上摆放的法术,玩家可以选择再后续的任意回合生效;但通过 focus 预备的法术则必须在下一回合用掉。

每个玩家角色有一个独特的能力,这个能力需要 charge 后才可以使用。每次 charge 的费用是 2 水晶,不同角色使用能力需要的 charge 数量各不相同。

这个游戏规则中比较独特的是:玩家回合结束后,未使用的卡牌不会自动丢弃,但摸牌是将手牌补齐五张。即,打的牌少,卡组循环也会变慢。而弃牌是有次序的,正面朝上依次置入弃牌堆。抽牌堆用完后,弃牌堆不洗牌,直接反过来形成新的抽牌堆。

游戏的随机性不来源于抽牌堆的乱序,而是每个轮次的次序。每个轮次,玩家和敌人的指代物会放在一起洗混,其随机次序决定了这个轮次中,哪个玩家(如果有多个)以及敌人谁先行动谁后行动。

末日决战这几年口碑不错,一直在发新的扩展。尤其是新的版本加入了传承机制,也就是游戏可以一局局玩下去,玩家可以升级和继承过去的能力。玩家主要的升级是角色本身的技能,敌人也会逐步加强。


另一款同期发行,口碑不错的合作类 DBG 是 Clank!: A Deck-Building Adventure (2016) 。

这款游戏的玩法不是纯粹的打牌,而是加入了版图移动机制。玩家需要在版图上移动获得神器和宝藏(计分)。它并不算是一个合作游戏,更像是玩家有一个共同的敌人(系统),而玩家之间则需要比拼谁拿的分更多。

系统会攻击玩家,当至少有一个玩家 HP 减到零时,游戏会进入结束倒计时阶段。获得一个神器并逃离游戏的玩家会获得额外计分奖励。玩家需要在版图上获得一个神器的基础上尽可能的得分(获取宝藏)并在游戏结束前脱离。玩家如果没能获得神器,得再多分也不计算。游戏的结束条件是由第一个被击倒的玩家开启,但第一个被击倒的得分损失很大,所以玩家应尽可能的活得更久。

游戏规则中比较有特色的是 Clank 系统(也就是游戏的名称)。有些卡牌效果会给玩家增加 Clank 方块的数量。而系统发起攻击时,会把所有玩家获得的 Clank 方块以及系统方块一起放入抽取袋再抽出来。抽到 Clank 数量决定了对应玩家会掉多少 HP ,所以 Clank 越多(尤其是比队友多)意味着会更快挂掉。系统方块更多意味着玩家受伤害的概率越少。不过,每次系统发动攻击抽取出的黑色方块(系统方块)都不会再次放入抽取袋中,这就意味着系统攻击造成的伤害会越来越大。

市场(地城)上有三类卡片:一次性效果卡、小怪、新的可以加入卡组的卡片。这和 Ascension 非常的类似:玩家可以选择不同路线增强自己的能力,杀怪或购买更强力的卡片。

市场上除了随机卡堆翻出的六张卡外,还有固定卡堆:一个技能点及两点攻击(剑符号)的雇佣兵,两个技能点及一点移动力(腿符号)的探索卡、价值 7 分的宝藏卡、需要 2 点攻击击败的小怪哥布林。

每个玩家行动结束后,都会把市场补全六张。当市场上得卡上出现龙符号时,系统发起攻击。

玩家的牌主要有三种能力:

  • 技能点,用来在市场上购买卡片。
  • 攻击力(剑符号),用来杀掉市场上出现的怪物卡。
  • 移动力(腿符号),用来在版图上移动。

击败怪物可以获得金币,这是和技能点不同的第二种游戏货币。金币在游戏结束时可以用于计分,也可以在游戏过程中花掉购买物品。物品有固定的三种,价格均为 7 金币:

  • 钥匙,用来在版图上快速移动(使用通道)。
  • 背包,可以携带额外的神器,每个额外的神器价值 5 分。
  • 皇冠,价值 8,9,10 分。(先买的玩家收益更高)。

打牌过程和传统的领土一样:每个回合的手牌必须全部打出,不可保留到下一回合。每个回合抽取新的 5 张手牌。抽牌堆用完后,洗混弃牌堆形成新的抽牌堆。

Clank! 在最近几年也一直在出各种扩展,同样发行了传承版本。

传承机制加入后,游戏更有故事性。每个章节会加入一些事件卡以及每局游戏的合约(单局游戏目标)。玩家需要扮演不同角色(有一点不同的能力),并可以随着游戏进程增强能力,游戏版图也会慢慢开放。

Windows 10 如何把文件关联到 Store App 上

作者 云风
2025年3月11日 11:13

我在 Windows 10 下倾向于通过 store 安装 app ,而不是使用独立的软件安装包。这样安装的 app 会比较安全。但正因为这些额外的安全措施,有些过去很容易做的事情却变得麻烦了。比如,把一个特定类型的文件关联到 app 上。

我习惯用 IrfanView64 查看图片。在游戏开发中常见的 .dds 文件它也可以打开(需要额外安装官方插件)。但 IrfanView64 在提交 store 发布时没有提交 .dds 这个后缀名,这导致 windows 10 无法选择用它打开 .dds 文件。在自选打开特定文件类型文件的设置菜单里(设置-默认应用),你会发现怎么都找不到这个应用。

通常,你很难找到 store app 的执行文件路径。它在 %ProgramFiles%\WindowsApps 目录下,这是一个特殊的目录,受 Windows 系统保护。但可以在启动应用后,开启任务管理器,找到对应进程打开文件所在位置找到它。

我通过 google 找到了一篇吐槽文,也是谈这个问题。这篇 blog 也是通过这个方法找到了 store app 执行文件的位置。有了这个地址似乎就可以在打开文件的设置菜单设置本地程序路径了。

但估计是最近的 windows 更新又进一步增强了安全机制,即使你这样设置打开 .dds 为 Irfanview64 app 本地安装的程序路径,双击 .dds 文件既然会出错。(缺少访问该程序的权限)

我想还是得自己动手改注册表。打开 regedit 注册表编辑器,找到 \HKEY_CLASSES_ROOT\.dds\OpenWithProgids 这里是 .dds 的备选程序列表。ps. 如果从来没有设置过 .dds 的关联程序,可能没有 OpenWithProgids 这个子项。创建出来即可,或随便设置一次 .dds 的打开方式也会被系统创建出来。

下一步在这里添加 Irfanview64 这个 app 项。每个 store app 都有一个 id 。Irfanview64 的为 AppXhg16hybbkbv7j3fk4s35ykmfvp63yx63 。其实我也不知道怎么获得这个 id ,但我的系统上 .jpg 或 .png 等都可以通过 Irfanview64 打开,找到 \HKEY_CLASSES_ROOT\.jpg\OpenWithProgids 把它抄过来就好了。

一旦添加了对应的 key ,然后再去 .dds 下选择打开应用,就会发现在右键菜单里多出来 Irfanview64 app 的选项。

2D 渲染管线的一点优化

作者 云风
2025年2月20日 10:46

考虑到我想做的独立游戏并不需要以画面效果吸引人,游戏是策略向的,所以 2D 表现就足够了。之前几年做的 3d 引擎对这个需求来说太复杂了,而且这次我也不打算主打移动平台,之前为移动平台做的考虑也没太大意义。所以,最近想花个把月重新搭一个 2d 游戏用的框架。当然,最重要的是:我太久没写代码了,而做这个非常有趣。

前天在 github 上开了一个新坑,具体想法写在项目的讨论区了

虽说 2d 游戏在如今的硬件上,性能那是相当富裕。但在具体写代码时,还是忍不住想想怎么设计,性能会比较好。不然总是重复大家都有的东西也是无趣。

在现代 GPU 上实现一个最简单的 2d 管线,就是把它当成 3d 网格,一堆顶点数据填进去,绑定贴图,提交渲染即可。所谓 2d 图片,就是两个三角形,看成是 3d 世界里的一个面片即可。

所以,每个顶点的数据就是 vec2 pos ,要画一个矩形需要四个顶点,用 vertex buffer 传进去。

但是和 3d 游戏不同,2d 图片形状大多不规整,不是边长为 2 的幂的正方形,尺寸也不大。如果每张小图片(2d 游戏中通常称为 sprite)都构造一张贴图的话,会非常低效。通常我们会把很多 sprite 打包在同一张大的正方形的贴图上。这样,顶点数据中还需要定义绘制矩形对应在贴图上的区域,通常称之为 uv 坐标。至此,常规的实现方法中,每个顶点就是 4 个数据量:vec2 pos 和 vec2 uv 。因为 sprite 都在同一张贴图上,一次图形指令提交只画一个矩形就太浪费了,我们会把多个矩形的顶点放在一起,一次把整个顶点数组提交到 vertex buffer 中。

虽然 2d 游戏的大多数 sprite 只需要指定屏幕(画布)坐标渲染即可,画布可以整体缩放。sprite 单独缩放旋转的机会比较少,但也并非没有。用上面的方法怎么处理旋转和缩放呢?过去常见的方法是在 CPU 中计算好四个顶点,把结果填在顶点数据流中。btw, 很早以前,我在实现 ejoy2d 的初版时就是这么做的。这样最为灵活,CPU 计算一个 2x3 的矩阵也不慢(ejoy2d 使用了定点数,在早期的手机上性能更好)。而且,大多数 sprite 并不需要旋转和缩放,只需要做一次 vec2 的加法即可。

计算该怎么做?我们需要找到 sprite 的基准点。大多数情况下,这个基准点并不是图片的左上角。然后以这个点为坐标原点,对 sprite 的四个顶点依次做旋转和缩放变换再加上 sprite 的绘制位置。这一系列运算相当于乘一个 2x3 的矩阵。如果我们想把这个运算放在 GPU 该怎么做?顶点数据流中就不能直接放顶点计算结果的坐标了,而应该放针对 sprite 的基准点的相对坐标,以及一个 2x3 的变换矩阵。这样,顶点数据就变成了:vec2 offset ; vec2 uv ; mat2 sr; vec2 t; 一共是 10 个数据。

很明显,后面这个 mat2 sr; vec2 t; 在数据流中重复了 4 次(一个 sprite 的 4 个顶点有相同的 2x3 矩阵)。另一方面,绝大多数的 sprite 不需要旋转和缩放变换,这种情况下,mat2 sr 都是单位矩阵;即使有旋转变换,旋转角度也是有限的。整个数据流中必然存在大量重复的 mat2 sr 。怎么优化掉这些重复数据呢?我们可以用一个 storage buffer 保存唯一的 mat2 sr ,在顶点流中保存一个索引 index 即可。这样,顶点数据就剩下 vec2 offset; vec2 uv; index; vec2 t; 7 个数据。最后这个 vec2 t 不放在索引中是因为大多数 sprite 会有不同的位移坐标,而 2x2 的 SR 矩阵更容易合并。

接下来的问题是,index 和 vec2 t 还是重复了 4 次。为了去掉这个重复,我们可以采用 instance draw 或 indirect draw 。理论上用 indirect draw 更合适,但它对图形 api 版本要求高一些(如果想运行在 web 上,还是需要考虑这点),所以我选择用 instance draw 实现。

使用 instance draw 的一个额外好处是可以省掉 index buffer ,使用三角条带描述矩形即可。

但 instance draw 有个问题:它最初是为了把一组顶点数据重复渲染设计的。而这里,我们有很多不同的矩形需要同一批次渲染。即,vb 中每组数据 vec2 offset; vec2 uv; 有很多组。所以,我选择不使用顶点数据流,把这组数据放在另一格 storage buffer 中,然后在顶点着色器(vs)中通过 gl_InstanceIndexgl_VertexIndex 索引它。

做到这里,我注意到:2d 游戏中的 sprite 矩形都是轴对齐的。所以,描述四个顶点并不需要 8 个量,而只需要 4 个,保存两个对角顶点即可。另外,offset 矩形和贴图上的 uv 矩形形状也是一致的,我们只是把贴图上的一个区域完整映射到画布上,这样还可以少两个重复信息。最终,我们只需要 3 对 vec2 就可以表达一个矩形以及 uv 。

而图片是以像素为单位的,贴图尺寸不会有几万像素大。这个坐标量使用 int16 足够了。所以在保存 sprite 元信息的这个 storage buffer 中,每个图元其实只需要 6 个 int16 ,也就是 12 字节足够了。最终,绘制每个 sprite 的数据为 6 short + 3 float ( x,y,index ) = 26 字节。

最终的 vs 是这样的

layout(binding=0) uniform vs_params {
    vec2 texsize;
    vec2 framesize;
};

struct sr_mat {
    mat2 m;
};

layout(binding=0) readonly buffer sr_lut {
    sr_mat sr[];
};

struct sprite {
    uint offset;
    uint u;
    uint v;
};

layout(binding=1) readonly buffer sprite_buffer {
    sprite spr[];
};

in vec3 position;

out vec2 uv;

void main() {
    sprite s = spr[gl_InstanceIndex]; 
    ivec2 u2 = ivec2(s.u >> 16 , s.u & 0xffff);
    ivec2 v2 = ivec2(s.v >> 16 , s.v & 0xffff);
    ivec2 off = ivec2(s.offset >> 16 , s.offset & 0xffff) - 0x8000;
    uv = vec2(u2[gl_VertexIndex % 2] , v2[gl_VertexIndex >> 1]);
    vec2 pos = uv - ( off + ivec2(u2[0], v2[0]));
    pos = (pos * sr[int(position.z)].m + position.xy) * framesize;
    gl_Position = vec4(pos.x - 1.0f, pos.y + 1.0f, 0, 1);
    uv = uv * texsize;
}

再来看 CPU 侧的设计:

我这次使用了 sokol 做底层图形 api 。sokol api 不支持多线程,所有图形指令必须在同一个线程提交。所以我做了一个简单的中间层:绘图时不直接调用图形 api ,而是填充一个内存结构。这个结构被称为 batch ,不同的线程可以持有多个不同的 batch 。所有 batch 汇总到渲染线程后,渲染线程再将 batch 中的数据转换为图形指令以及所需的数据结构。

因为 2d 游戏据大多数情况都在处理图片,使用默认的渲染方式。我对这种默认材质做了特别优化。batch 是由这样的结构数组构成:

struct draw_primitive {
    int32_t x;  // sign bit + 23 bits + 8 bits   fix number
    int32_t y;
    uint32_t sr;    // 20 bits scale + 12 bits rot
    int32_t sprite; // negative : material id
};

其中,用两个顶点 32bit 整数表示 sprite 的画布坐标;一个 32bit 整数表示旋转和缩放量;一个 sprite id 。

渲染层会查表把 sprite id 翻译成对应的元信息(上面提到的 offset 和 uv ),当 sprite id 为负数时,表示这是一个非默认材质,batch 中的下一组数据是该材质的参数。例如,文本渲染就会用到额外材质,文本的 unicode 和颜色信息就放在接下来的数据中。

卡牌构筑类桌游核心规则之四

作者 云风
2025年2月16日 14:44

这篇谈谈 PvE (玩家对抗环境)向的卡牌构筑类游戏。

在桌游中,PvP 向(玩家对抗玩家)的游戏数量明显超过 PvE。我认为这是因为桌游需要靠玩家自己驱动游戏规则,扮演环境的同样是玩家,其规则不能设计的太复杂,通常只能靠简单机械的逻辑驱动。或者,起源于桌游的 RPG ,比如 D&D ,则由一个玩家扮演环境(城主),这样就可以增加游戏的深度。但毕竟这种不对称规则下,玩家方和环境方很难调配平衡。RPG 这样的游戏,城主也并非玩家的对立方,大家只是在一起享受游戏过程。而在电脑上,则可以通过程序实现更复杂的环境。所以在电脑游戏中,大量的游戏转向 PvE 。毕竟,找到可以一起玩的游戏搭档并不容易。

所以,对于卡牌构筑这个具体类别,电脑上的游戏几乎一开始就是 PvE 性质的:比如杀戮尖塔,玩家一直在挑战系统而获得乐趣;而桌游中,从领土(Dominion)开始,就是基于玩家对抗设计的规则。

传奇 Legendary 系列是一个比较早的 PvE 向卡牌构筑类桌游系列。最早可以追述到 Legendary: A Marvel Deck Building Game (2012) ,后续几年发布了大量系列作品,并衍生出 Legendary Encounters 系列。关注 Legendary 系列是因为星际孤儿(Stellar Orphans)这个电脑游戏,我特别喜欢。在星际孤儿的玩家社区,有玩家指出这个游戏明显受到了 Legendary 系列桌游的启发。

传奇系列的每个作品都围绕一个题材展开,以最初的漫威系列为例,下面我介绍一下它的核心玩法规则。


游戏是由玩家对抗系统。每个玩家以开始会拿到一叠基础的英雄卡(12 张),系统则由一叠系统卡设定。

每个回合,从系统卡堆中抽出一张卡片出来推动游戏发展。如果从系统卡堆中抽到坏人卡,则表示这张坏人卡会入侵城市(摆放在桌面区);如果抽到旁观者,则绑定在坏人卡上,变成坏人的俘虏;如果抽到事件卡,则引发特殊事件。

而在系统卡结算完毕后,玩家从手牌中打出卡片组合,产生本回合的攻击点、招募点以及特殊能力。攻击点可以用来消灭城市中的坏人卡(并解决俘虏);招募点则从桌面的 HQ 列(从英雄卡堆中翻出)购买新的英雄卡增强自己的卡组。而传统的卡牌构筑类游戏规则一致,每个回合的卡片都必须全部用完,不可保留到下个回合,溢出的攻击点和招募点会作废。购买的新卡片也会先置入弃牌堆。每个回合,玩家从自己的卡组中抽取新的手牌(6 张);一旦卡组抽完,洗混弃牌堆,形成新的抽牌堆。

初始卡组由 8 张基础的招募点卡(每张 1 点)以及 4 张基础的攻击卡(每张一点)构成;英雄卡堆(形成市场)则由玩家选择的几个英雄对应的卡组混在一起。每个英雄卡组有 14 张,其中两组各 5 张相同的普通卡,三张强力卡,以及一张稀有卡。市场上永远展示其中的五张,当玩家购买后会补齐。一旦英雄卡堆用玩,游戏结束。另外,市场上永远有固定的高级招募卡(每张 2 点)可供选购。它类似于 Dominion 中的银币。玩家每个回合还可以花 2 点招募点买到一张 sidekick ,该卡只能使用一次(用完回到市场),效果是抽两张卡。

每局游戏,系统存在一个终极 boss ,在 setup 阶段需要随机选择 boss 对应的一张 scheme 卡。scheme 卡上描述了系统胜利的方法。当系统达成条件,玩家就会输掉。

攻击 boss 他需要大量攻击点,每次成功的攻击会结算一次随机的 Tactics 卡(例如,有些 Tactics 卡会增加下一次攻击 boss 的难度)。每个 boss 对应 4 张 Tactics 卡,四次成功攻击后,玩家就赢得游戏。

系统每个回合翻出的坏人卡会以队列形式在桌面推进。桌面一共有 5 个位置(地点),坏人卡从最右侧进场,在进场时需要结算坏人卡上的 Ambush 效果(若有)。如果玩家一直对翻出的坏人卡置之不理的话,坏人卡会一步步向左推进,直到离开桌面。每离开一张坏人卡,都会摧毁掉市场上的一张卡片(由玩家自己选择);如果坏人带着俘虏离开,则还要弃掉一张手牌。如果逃离的坏人卡上标注有 escape 效果,还需要额外结算。

系统卡堆中存在一些地点卡,翻出后在场上并列一排从右至左一张张排列直到排满。摆满后,新的地点卡会替换掉场上最弱的那张。地点卡会改变存在这个地方的坏人卡的能力。地点卡本身可以作为攻击目标。

系统卡堆还包含一些特殊卡(坏人卡和旁观卡之外),它们不推进坏人卡。其中:

Trap 卡给玩家一个需立刻结算的挑战。

Twist 卡会推进当前 Scheme 卡上系统胜利的某种进度。

Strick 卡会触发 boss 的攻击。

玩家的手牌没有打出费用(类似杀戮尖塔的行动点限制),但受招募费用的限制,强力卡在一开始无法从市场购得。玩家倾向于在每个回合打出所有手牌(不打出的卡也无法带到下个回合)。基本卡片只是一个招募点和攻击点,把这些点数累加起来就是当前回合可以用于购买新卡以及消灭坏人的费用。但强力卡片的打牌次序是有选择的。因为卡片会有专门的特殊能力,比如,有的卡片需要弃掉别的手牌才能使用;有的需要前置打出某些类型的卡,就有额外的能力加成(形成 combo)。

此外,击败的坏人卡、拯救的俘虏(旁观者)、Tactics 卡都附带有 VP 。当以多人协作形式进行游戏时,每个玩家单独计算 VP ,在游戏结束后,可以比较在游戏过程中获得的 VP 总数来觉得谁表现得更好(但玩家在游戏过程中依旧是合作关系,而不应该为 VP 竞争。


在 Legendary 系列之后,同一家公司推出了新的 Legndary Encounters 系列。这是换了新设计师后在 Legendary 规则上的进一步发展。系列的第一作是 Legendary Encounters: An Alien Deck Building Game (2014) 。给我的感受是,Legendary Encounters 更加注重叙事(而不仅仅是围绕一个主题),通过卡片和游戏规则的设计,玩家可以更好的融入游戏故事中。不同的题材会给玩家不同的感受,Legendary Encounters 在今年(2025 年)还会有新作推出,最新的故事看起来会在冰与火之歌的世界中展开。

我在桌游模拟器中尝试了一下最初的异形(Alien)三部曲。和异形电影体验非常接近,代入感很强。和 Legendary 漫威不同的地方是:

敌人(坏人卡)是背面朝上在桌面(场景)中推进的。玩家需要主动 scan 。这和星际孤儿的设定非常相像(应该是它启发了星际孤儿)。玩家对敌人的推进置之不理的话,敌人牌移到头会触发攻击,而受到太多攻击后,玩家会死亡而输掉游戏。

多人协作模式下,不同的玩家会扮演不同的角色。不同角色的能力是不同的。另外,玩家在游戏过程中有可能因为不敌敌人而被感染,当玩家以这种形式被杀死后,会重新以异形的立场加入游戏,变成对抗其它玩家。

每局游戏会有固定的几个阶段,每个阶段有固定的目标。这些阶段性目标被设计成和电影情节一致。玩家需要完成每个阶段的目标推进游戏,直到所有阶段达成获得游戏胜利。btw, 星际孤儿也采用了这种形式,并进一步加长了游戏故事。

卡牌构筑类桌游核心规则之三

作者 云风
2025年2月3日 20:39

这一篇,我想先谈一个在中文社区比较小众(没有出中文版),但我个人非常喜欢的卡牌构筑游戏:核心世界 Core Worlds (2011) 。

相对 Dominion 来说,它的规则和体验已经非常不同了。

它的单位牌需要先部署到桌面(战区),而后在从桌面打出,用于征服星球(用于增强能力)。这可以促使玩家做更多跨回合的规划。而不是仅考虑当前回合的手牌怎样打出漂亮的 Combo 。每个回合,玩家可以在回合结束时保留一张手牌(同时也会减少抽牌数量),这点也是为了促进玩家更多的考虑回合间的联系。

游戏采用固定轮数,一共 10 轮 5 个阶段。每个阶段的市场牌堆都是独立的。所以,玩家会在每两轮看到不同的牌进入市场,这样会减少卡组构建的随机性,让强牌逐步出现。游戏节奏被设计的更好。 每个轮次翻到市场的卡很少,只玩一两盘恐怕大多数卡片都不会见到,这加强了重玩性。每局游戏都有不同的变化。虽然游戏有 10 轮,但游戏节奏其实是很快的。尤其是到了终局的第五阶段,节奏被刻意加快了:前 8 轮积累的大量能量,而手牌也增加了。没有新的单位卡,而换成了得分用的声望卡以及核心世界卡需要竞争。终局两轮实际是考验的是玩家前面的组卡和布局成果。

而在前面的回合,玩家每轮可以做的事情并不算太多。市场上的卡片分为星球卡和单位/行动卡。星球卡需要玩家用之前部署的部队去征服,被征服的星球卡直接放到玩家桌面提供永久能力(通常会增加能量点);而单位/行动卡则需要能量点购买,和传统的卡牌构筑规则相同,新购入的卡片需要先进入弃牌堆。市场上的卡片张数是受玩家人数限制的,每轮固定添加新卡片上场。每轮新增加的卡片如果没人搭理,则会增加一点能量奖励下一轮选它的玩家。而两轮不选的卡片则自动弃掉。这样,市场上永远都只有有限几张卡片(根据玩家人数不同而不同),玩家不会陷入选择焦虑。

在战斗征服部分,玩家单位被分为地面战和空中战两种能力。针对不同的星球,需求不同。但这些能力有可以被战术(行动)卡所改变。因为用于征服星球的单位需要花一轮部署在桌面(战区),所以,玩家可以通过观察对手部署的部队,提前了解对手的意图。实际玩的时候,先做什么再做什么,会随着对手的行动而不断变化。加上市场资源(无论是星球还是行动卡)都极为有限,游戏的对抗性就变得很强。

在实际玩游戏的时候,除了自己行动需要决策外,观察对手行动在做什么也相当有意义。这让轮转行动时等待对手行动的时间也不会枯燥。


游戏每轮分为 4 个阶段:抽牌、补充能量、补充市场、行动、结束。前三阶段玩家的选择非常有限,所以进度会很快。游戏的核心时间会花在行动阶段上。

在行动阶段,玩家受行动点和能量的双重限制。每类行动都会花掉一个行动点,玩家以行动为单位轮转。而不同行动的能量点开销各不相同,玩家可以做的是:

  1. 根据市场上的单位/行动卡标注的能量费用购买一张新卡(置入弃牌堆,无法立刻使用)。

  2. 把手上的单位卡部署在桌面战区(支付对应的能量点),只要能量够,一次可以部署多张单位卡。

  3. 使用已经部署好的单位卡去征服一个星球。同时可以利用手牌中的战术行动卡增强能力。

  4. 有部分战术卡可以单独作为一个行动使用。

玩家可以在行动阶段 pass ,能量点不会保留到下个回合,且需要弃掉手牌。和同类游戏相比,核心世界在弃掉手牌时可以保留最多一张卡片。但由于回合开始抽牌阶段的规则时补充手牌到规则上限,所以,保留手牌会减缓卡组轮替。

在最后的结束阶段(所有玩家 pass 行动),不需要玩家做行动。只是清理桌面:玩家在本回合获得的额外能量点(通常是因为选择了上一轮每人要的卡片)会累加在能量条上供下一轮使用。而两轮都没人过问的卡片则被移除游戏。

在 10 轮之后,玩家统计各自的 VP 决定谁最终胜利。大部分的星球会有 VP ,而在最后一个阶段的两轮,市场上会出现大量的 VP 卡。声望卡只需要能量就可以购买,它需要玩家在前面累积大量的能量产能;核心星球则提供多样化的 VP (和玩家卡组里的其它卡片组合算分),核心星球通常需要玩家在前 8 轮部署足够的战力才能拿下。


这个游戏提供了有限的卡组瘦身机制。我们知道,一般的卡牌构筑游戏,卡组越精简,卡组循环就越快,卡组实力便越强。把单位卡部署在桌面是一种临时缩减卡组的办法;而征服星球则可以从征服部队中选一张单位卡殖民到该星球上,这可以让玩家从卡组中永久去掉一张卡不参与循环(但依旧参与计分)。

另外,在熟悉游戏后,初始会有两张不同的单位卡(一张地面战力,一张空中战力)采用轮抽机制替换。这也增加了多局游戏的多样性。

核心世界还有两个扩展,可惜我没买到。这里就无法评说了。


接下来谈的一款游戏在中文社区就比较大众化了:星域奇航 Star Realms (2014) 。

这是一款快节奏的双人对战卡牌构筑游戏。我有一套正版的基础版,它出了很多扩展,但现在都很难买到正版了。所以我只好买了大盒装的盗版体验。

星域奇航上手非常简单。就是抽牌,从市场购买卡片、打牌攻击对手。和 Dominion 不同,它并不靠积累 VP 获胜,而是攻击打掉对手的血获胜。每个人初始有 50 点血(官方称为权威点),在初始手牌中,便有一点攻击力的卡片,抽到即可攻击。初始手牌中大部分卡片是钱币卡。10 张卡片组成的标准起始卡组为 8 (钱币) + 2 (攻击) ,这和 Dominion 的初始卡组非常类似。

市场区和 Ascension 类似,采取买一张补一张的轮换制。但市场区没有怪物卡,全部是单位卡。因为游戏的战斗全部是攻击对手,不靠打怪得分。btw, 星域奇航的作者本身就设计了 Ascension 的很多扩展,所以就不难理解这个游戏和 Ascension 的很多相似之处了。

单位卡除了可以在当前回合发动能力的飞船(提供钱币/攻击/补血等)之外,还有一种要塞卡,可以在打出后永久停留在桌面。要塞卡通常会提供一些持久能力,也可以用于阻挡对手的攻击。星域奇航创造的一条独特规则:部分卡片可以在打出后将自己销毁,提供额外的一次性能力。这种销毁发动一次性能力的卡片也包括要塞卡。所以玩家在打牌时就多了一种选择:要不要触发销毁能力。

(基础)游戏中有四种势力,分属为四种卡牌。通常单一势力的卡组更容易建立起强大的 combo 。组卡方面留下的策略空间不错,堆整个卡池越熟悉,就越容易组出强大的卡组赢得游戏。

总的来说,我还是很喜欢这个游戏的。因为一局时间比较短,规则又简单,可以经常在家里和孩子一起玩。这里再次强调游戏节奏很快,是因为它相比那些卡牌构筑前辈,可以用更短的回合数构筑出攻击引擎,精简卡组也相对容易,往往一个有趣的组合就可以打爆对手。我们玩一局的时间很少超过半个小时。

在加了扩展后,游戏还提供协作模式:由系统制造出一个强大的 boss ,两个人不再相互攻击,而是要协力击败系统。云豆更喜欢协作模式一些,只是把系统设定的几个 boss 都击败后,重玩不够有趣。


在写了这么多对抗类的卡牌构筑游戏后,下一次我想改谈协作类型了。由玩家对抗系统,或许更接近我自己要做的电脑游戏一些。

卡牌构筑类桌游核心规则之二

作者 云风
2025年1月11日 19:53

上篇提到的 Dominion 和 Ascension 都没有使用传统 TCG 中打出卡片的费用设定。TCG 游戏中给卡片设定不同的费用很好理解:在游戏前自由组卡的游戏,如果不给卡片设定打出限制,玩家只需要尽可能的选择强力卡片放在卡组里,游戏过程中先抽到的人就会占据优势,这就变成了一种运气游戏。

卡牌构筑游戏更多的是用购买卡牌的费用来做限制,让强力牌是逐步加入战场的。玩家不能一开始就购入强力卡片,即使到可以购买的中期,卡片也会先进入弃牌堆,相当于等待一段 CD 时间才能抽回手上。但只有这还不够,必须考虑游戏中后期的平衡:在牌组构筑成型后,一套强力组合若能轻易打出,也会让游戏一边倒。所以,还是需要对打出功能卡做出一些限制。

Dominion 的做法很简单,默认每个回合,玩家只能打出一张行动卡。如果需要打出多张行动卡,就需要配合加行动次数的行动卡。而且加一行动的卡片只能让自己加入当回合的行动链中,只有加二以上行动的卡片才能拓展行动选择。同时,购买牌的次数也被限制了,必须通过行动卡扩展。

Ascension 不一样,它即不限制每个回合打牌的数量,也不限制购买数量。所以几乎每个回合,玩家都会打出手上的所有牌,变化只在于打牌的次序。那么,平衡是怎样实现的呢?这个游戏我玩的不多,只能谈一下粗浅的理解。

Ascension 提供了两种得分手段:用符文购买牌时,除了新牌加入牌组增强实力外,还额外附带了 VP ;使用力量杀怪,直接获得 VP token 。玩家很难在构筑时同时考虑两方面:增强符文能力(更容易买到新牌)以及增强力量(更容易杀怪)。而中间公共市场列是随机抽出来的,每去掉一张牌,无论是购入私人牌组,还是杀掉怪进入虚空区,都随机出现一张新卡。即使在后期玩家牌组能力够强,同一回合有超强的买牌能力(符文充足),也会造成市场列出现全是怪打不动的情况;反之亦然,怪一下子杀的太多,中间就没有怪了。

所以,即使有强力的手牌组合,也很难全部发挥它们的能力兑换成同价值的 VP 。公共市场区的随机性造成了一定的制约。再者,Ascension 公共市场的竞争远较 Dominion 的市场高,玩家更不容易组出心目中的理想卡组。

我比较关注游戏还会用什么规则去限制手牌打出。毕竟游戏必须给予玩家不同的有意义的选择,如果每次拿到手牌就无脑全部打出也太无趣了。只有在限制下,玩家才需要决策打哪张更好。在传统卡牌战斗游戏中,传统的方案是给行动卡加上费用,强卡费用高一些,弱卡低一些。但大多数 DBG 都没有这个设定。我认为这是因为其成本在购牌时已经区分过一次了。当然也一定有游戏采用这种经典机制,比如我非常喜欢的 Core Worlds (2011) ,后续我会详谈。

卡牌的游戏过程,能够产生的变化(玩家的选择)主要可围绕三个方面:打牌、弃牌、构筑。可以把效果和限制分别加在这些行为之上,让玩家去选择针对哪张卡片实施何种动作,以及这些动作的执行次序就是规则的设计空间。

Nightfall (2011) 夜幕降临 是一个打牌机制非常独特的卡牌构筑游戏。只要玩一盘就能感受到和 Dominion 的游戏感受非常不同。

这是一个以玩家之间混战为主题的游戏。和 Dominion 一样,计分也是做成卡牌会混入玩家卡组的。但和 Dominion 的玩家尽力购买高分卡片不同,Nightfall 的计分卡片可以视为负分。玩家应该避免把它加入卡组。它把玩家所受到的伤害做成卡片,一旦玩家被攻击成功,就需要加入伤害卡到自己的卡组。当公共卡池的所有伤害卡耗尽,游戏结束。私人牌组里伤害卡最少的玩家获胜。

伤害卡没有实际功能,在常规 DBG 中,这类卡会降低卡组质量。为了避免让领先玩家形成正反馈,所以游戏加入一条规则,只要手牌中有伤害卡,每张都可以再抽两张手牌。但每个回合只能执行一次,即用这个能力新抽出来的伤害卡不可以继续额外抽牌。

夜幕降临打出卡片采用了一个独特的六色轮系统。每张卡片属于六色之一,并有一到两种后续关联颜色。玩家每个回合需要按颜色连锁出牌。例如,一张卡片的颜色是红色,连锁色是蓝色和绿色,那么打出这张卡后,可以再打一张蓝色卡或绿色卡,而不能打出其它颜色的卡片。每张卡片上还有激活颜色,如果符合上一张打出卡片的颜色就可以获得额外的能力。一套连锁打出后,连锁效果以反向次序结算,即最后打出的卡片最先结算。另外,游戏对连锁机制还做了一个巧妙的设计:当一个玩家无法进一步连锁出牌,其它玩家可以在该玩家的回合内加入连锁。也就是可以在对手回合出牌。

连锁机制在传统 TCG (万智牌)中非常常见,而在 DBG 里比较少见。这恐怕是因为 DBG 比较注重构筑过程,所以一般游戏会设计一些资源卡用来实现从市场买牌的构筑过程。这种发展过程在 TCG 中是比较弱化的,且预先组好的卡片也更容易实现玩家的连锁目标。如果在 DBG 里实现同样的效果,就需要让更多的卡片参与战斗功能,卡片的能力之间也不要差距太大,方便玩家构筑套路。

夜幕降临去掉了纯粹的资源卡。每个回合都直接给玩家两点费用用于购牌。额外的费用除了放在部分卡牌本身的功能上,也允许玩家通过弃手牌的方式换得更多资源。用手牌做费用的方式我最早在银河竞逐中见到,这是一种不错的设计,能提供给玩家更多决策空间:是在当回合打更多牌出来,还是留一部分牌用于发展(购买新卡)。

夜幕降临玩起来非常激烈。和传统 TCG 不同,打出的仆从卡在下一个回合必须尽数用掉,只做一次性攻击结算后就进入弃牌堆。这相当于每个回合把牌打到桌面,固定结算一次连锁效果后,再延迟发挥一次攻击能力,待到轮回到自己行动时,桌面卡片就全部退场,玩家重新部署新的卡片。玩家每个回合都需要选择铺场,节奏很快。

值得一提的是,夜幕降临除了后续扩展包扩展规则外,在 2024 年以这个核心规则重置了一款新游戏 Unchained (2024) 。虽然是重置,但还是改变了不少细节。我觉得最值得一提的是市场机制从类 Dominion 的固定市场改为了像 Ascension 那样的随机市场,但并不是简单的只是从一个市场牌堆中随机抽出排成一列。

老的 Nightfall 的市场是以玩家一开始轮抽的方式决定 8 种公共市场牌堆和 2 种私有市场牌堆。除了私有市场的设定相比 Dominion 增加了一些变化外(放置玩家之间模仿别人的构筑方法),基本还是考验玩家以对卡牌的理解在开局时做出的策略规划。

而 Unchained 改用了 3x3 矩阵方式的市场,从洗乱的市场堆中铺满 9 张卡片供玩家选购。卡片的费用被简化为两级,普通卡和稀有卡。玩家必须先花一点费用从任一行或任一列中购买一张普通卡,然后才能再花两点费用从同一行(列)中购买第二张卡。只有第二张卡才能选择稀有卡,第一张不行。另外,游戏加入第二种资源 Talent 天赋点用来购买额外的卡片(不限制在同一行)。购卡费用还可以用来刷新市场,每点费用可以用来刷一行或一列,只要费用足够,可以无限执行。当市场卡堆耗尽,会洗牌重建。

据说这个设定来源于奥法之战 Res Arcana (2019) 的一个玩家变体。btw, Res Arcana 是我最喜欢的卡牌游戏银河竞逐作者的新作,虽然不属于 DBG ,但也是一个得分引擎构筑游戏。其玩法也值得一书。留到以后再记录。

Unchained 还放弃了固有的起始手牌设定。在 Nightfall 中,起始手牌用过一次后都会销毁,作者本就不打算让玩家在游戏过程中依靠它们,只做游戏初期启动使用。Unchained 干脆去掉了起始牌组,让每个玩家从普通市场卡堆中随机抽 5 张的方式作为起始手牌。这应该也是因为这个游戏重在卡片组合,Unchained 重新调整过平衡后,单张卡片强度差别不大,所以不必过于强调起始牌组的对称性。同时也进一步加快了游戏节奏:第一回合就可以打出丰富的行动。

还有几个小改动值得称道。

Unchained 的所有卡片都去掉了文字,只使用符号。这说明游戏不需要设计一些特殊卡片能力来平衡游戏。

每次玩家抽牌堆耗尽冲洗弃牌堆时会加入一张伤害卡,这应该是为了平衡玩家对卡组极端瘦身,只使用几张卡片循环游戏。

Nightfall 继承了 Dominion 的设计风格,尽可能把一切都设计成卡牌。比如计算卡片伤害就是通过旋转桌面上的卡片来表示的。Unchained 放弃了全部使用卡片这个追求,回归到传统使用不同的 token 来计量伤害和防御,购卡用的两种费用也改用 token ,可以累积。在 Nightfall 中,和传统 DBG 一样,当回合的购牌资源不用即废;而在 Unchained 中则可以通过 token 累积。

❌
❌