MoreRSS

site iconCodingNow | 云风修改

coder ( c , lua , open source ),不用微信和QQ的大佬。
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

CodingNow | 云风的 RSS 预览

欧陆风云 5 的经济系统

2025-11-05 06:07:40

很早从“舅舅”那里拿到了《欧陆风云 5》的试玩版。因为开发期的缘故,更新版本后需要重玩,所以一开始只是陆陆续续玩了十几个小时。前段时间从阳朔攀岩回来,据说已经是发售前最后一版了,便投入精力好好玩了 50 小时,感觉非常好。

我没有玩过这个系列的前作,但有 800 小时《群星》的经验,还有维多利亚 2/3 以及十字军之王 2/3 的近百小时游戏时间,对 P 社的大战略游戏的套路还是比较了解的。这一作中有很多似曾相识的机制,但玩进去又颇为新鲜,未曾在其它游戏中体验过。

我特别喜欢 P 社这种在微观上使用简洁公式,宏观展现出深度的游戏设计。我试着对游戏的一小部分设计作一些分析,记录一下它的经济系统是如何构建的。

这里有一篇官方的开发日志:贸易与经济 其实说得很清楚。但没自己玩恐怕无法理解。

我在玩了几十小时后,也只模糊勾勒出经济系统的大轮廓。下面是我自己的理解,可能还存在不少错误。

EU5 的经济系统由人口/货币/商品构成,市场为其中介。

游戏世界由无数地区构成。地区在一起可以构成国家,也能构成市场。一个国家可以对应一个市场,也可以由多个市场构成,也可以和其它国家共享市场。

每个市场都会以一个地区为市场中心,这反应了这个地区的经济影响力,它不同于国家以首都为中心的政治版图。市场自身会产生影响力,而市场中心地区的所属国家则因其政治影响力而产生市场保护力,两相作用决定了市场向外辐射的范围。每个地区在每个时刻都会根据受周围不同市场的影响强弱,最终归属到一个唯一市场。

每个地区有单一的原产物资(商品)。原产在版图上不会改变,可以被人口开发,计入所属市场供给。

地区上可以修建生产建筑,生产建筑通过人口把若干种商品转换为一种商品。转换效率受地区的市场接入率影响。市场中心地区的接入率为 100% ,远离市场的地区接入率下降,在边远地区甚至为 0 (这降低了同样人口的工作效率)。注:原产不受市场接入率的影响。

市场每种商品有供给和需求。每种商品有一个额定价格。需求和供给的多寡决定了目标价格和额定价格的差距,目标价格在 10% 到 500% 间变化。实际价格每个月会向目标价格变动,变动速度受物价波动率影响。

注:商品在每个市场有库存。库存和需求是独立的,库存多少不影响价格(供需状态影响它)。当需求超过供给时会消耗库存,反之则增加库存。库存有上限,一旦达到上限就无法进口。当贸易的交易对手需求大于供给时可以对其出口而无法进口,供给大于需求时可以从其进口,而无法出口;而自己只要有库存就可以出口(即使需求大于供给)。

商品的价格减去原料成本(原产物资没有原料成本)为其利润,利润以货币形式归属生产人口,并产生税基。

负利润的生产建筑会逐渐减员,削减产量,除非政府提供补贴。缺少原料的生产建筑会减产。

人口会对商品产生需求。食物类型的商品是最基本的需求。 不能满足食物需求的人口会饿死,不能满足其它需求的人口会产生不满。

对于单一市场:

  1. 人口在市场上产生商品需求。
  2. 生产建筑通过人口生产出商品供给市场。
  3. 市场上的供给和需求影响了商品价格。
  4. 人口通过生产获取货币形式的利润,并产生税基。

税基中的一部分货币留给人口,一部分以税收形式收归国库。

货币用来投资新增生产建筑,或对其升级。建筑升级需要商品,这部分商品以需求形式出现在市场。人口会用自己的钱自动投资建筑,玩家可以动用国库升级建筑。

多个市场间以贸易形式交换商品:

每个市场有一个贸易容量,贸易容量由市场中地区中的建筑获得。贸易容量用来向其它市场进出口商品。

市场所属国家拥有贸易竞争力,贸易竞争力决定了向市场交易的优先级。高优先级贸易竞争力的市场先消耗贸易容量达成交易。

商品在不同市场中的价差构成了贸易利润,其中需要扣除贸易成本(通常由两个市场中心间的距离决定)。贸易利润的一部分(由王权力度决定)进入国库,其它部分变为税基。

在国家主动进行贸易外,人口也有单独的贸易容量,自动在市场间贸易平衡供需。


我觉得颇为有趣的部分是这个经济系统中货币和商品的关系。

游戏中的生态其实是用商品构成的:人口提供了商品的基本需求,同时人口也用来生产它们。在生产过程中,转换关系又产生了对原料的需求。为了提高生产力,需要建造和升级建筑,这些建筑本身又是由商品转换而来。所以这么看来,是这些商品构成了这个世界,从这个角度完全不涉及货币。

但货币是什么呢?货币是商品扭转的中介。因为原产是固定在世界的各个角落的,必须通过市场和贸易通达各处。

建立一个超大的单一市场可以避免贸易,它们都直接计入市场中心。但远离市场中心生产出来的商品(非原产)受市场接入度的影响而削弱生产效率,所以这个世界只能本分割成若干市场。不同市场由于供需关系不同而造成了物价波动。价差形成贸易的利润,让商品流动。这很好的体现了货币的本质:商品流动的中介。

政府通过税收和其主导的国家贸易行为获得货币,同时也可以通过铸币获取额外收益(并制造通货膨胀)。再用这些货币去投资引导世界的发展:建造和升级生产建筑光有钱是不行的,必须市场上有足够的商品;没有钱也是不行的,得负担得起市场上对应商品的价格。

注:铸币可以看作是用黄金或白银直接变成货币的生产行为。它的基础产能似乎和人口无关,也不需要分配人口去工作,也没有特定的生产地点 。只需要在国家结余菜单中勾选即可。选择更大的产能会导致货币贬值,本质上是单位黄金/白银兑换的价值变少,但游戏中是以通胀变现的。即,生产出来的货币并没有变少,但国家的通胀增加了。铸币行为表现为在市场中自动消耗一定量的贵金属。如果整个市场中都没有贵金属,也就无法铸币。


11 月 7 日补充:

玩游戏的时候,一直没弄懂游戏中市场间的交易是怎么撮合的。官方 wiki 现在没有详细解释,游戏内的说明也不够完整。昨天和玩友讨论后,又仔细在游戏中研究了一下,发现其实游戏中说明还是很丰富的,只是细节散布在不同界面中。我是这样理解的:

由于同一市场中可能有不同国家的贸易建筑,所以导致了在一个市场中,不同国家分别拥有一定的贸易影响力和贸易容量。注:在友好的外国,可以修建贸易站,这种建筑占用当地的人口,为当地产生税基,但可以为本国带来贸易影响力和贸易容量。这些数值可以在市场界面上看到。

贸易影响力在出口紧俏商品时起作用,即商品的当月节余不能满足当月出口需求时,高影响力的贸易订单先被满足。在订单详情界面中可以看到相关商品的供给和需求细节。我不确定进口商品过多时是否也按影响力排序,游戏内的说明中说贸易影响力不影响进口,但我认为受商品仓库上限的影响,如果当月进口数量太多会超过库存上限,同样需要对进口订单排序。只不过在库存过高时,通常交易是负利润,很难出现这种情况罢了。

即使在一个外国市场内没有贸易容量,也可以发起贸易,只要该市场在你拥有贸易容量市场的贸易范围内。计算贸易距离似乎看的是市场边界的最短距离(而不是市场中心的距离),在贸易订单界面可以看到商品的出口地点和进口地点,很多时候并不是贸易中心地。贸易范围看起来是一个国家相关值。所以,在周边国家建贸易站可以扩展可以交易的市场。但如果在交易对手的市场内没有贸易容量,那么贸易影响力一定为零,对紧俏商品的采购很可能失败。

在两个市场间对某种商品进行贸易,会产生订单,同一商品只会有一张。订单上会指定商品数量,数量越多,消耗的贸易容量越大。每种商品有一个基础消耗比例,根据贸易距离乘一个系数。所以距离越远的贸易,消耗的贸易容量越大。在下订单时,并不能立刻确定是否能成交。如果商品库存不够,需要根据贸易影响力排序。低影响力国家发起的订单可能不能完全满足,甚至为 0 。这种情况下,订单所属的贸易容量就被浪费掉了。

每个订单可以看作是一支商队,贸易容量就是商队的规模。长途贸易需要支付额外的费用,可以理解为商队的工资以及海峡过路费等。这个费用以贸易容量为比例支付,而不是实际成就额。所以,即使订单数量为 0 ,这些额外费用也是要支付的。所以订单有亏本的风险。所以大容量订单(未能成交的)风险更大。如果是采购战略物资,采用多个小订单分散去不同市场采购获得足够成交数量更安全。

订单可以被人工固定,每月固定执行,但有更大的可能无法成交。大部分情况下,采用系统自动生成的订单。系统看起来会按利润多寡分配贸易容量。但似乎也不会产生过大的订单,具体规则没有写。总之,系统自动生成的订单浪费贸易容量的机率更小。

人口自身也有一定的贸易容量(独立于国家可以控制的贸易容量)自发进行贸易。对应的订单在游戏界面中无法查到。但在商品界面详情中可以看到一个合计数值,显示该商品由人口自发贸易行为产生的输入或输出总量。人口如何产生这些贸易的规则不清楚,从文字说明看,和人口自身的需求相关。我怀疑也和贸易利润相关。

关于桌游设计大赛的介绍

2025-10-12 17:57:22

这一篇是前几个月研究桌游规则期间的另一篇小结。因为最近两个多月都在制作 Deep Future 的数字版,没空整理笔记。现在闲下来,汇总整理这么一篇记录。

今年夏天,我迷上了 DIY 类型的桌游。这类桌游最显设计灵感。商业桌游固然被打磨的更好,但设计/制作周期也更长。通常,规则也更复杂,游戏时间更长。我经常买到喜欢的游戏找不到人开。阅读和理解游戏规则也是颇花精力的事情。所以,我近年更倾向于有单人模式的游戏。这样至少学会了规则就能开始玩。但为单人游玩的商业桌游并不算多(不太好卖),而我对多年前玩过的几款 PnP (打印出来即可玩)类单人桌游印象颇为深刻:比如 Delve 和同期的 Utopia Engine (2010)

在 7 月初我逛 bgg 时,一款叫做 Under Falling Skies 的游戏吸引了我。这是一个只需要 9 张自制卡片加几个骰子就可以玩的单人游戏,规则书很短几分钟就理解了游戏机制,但直觉告诉我在这套规则下会有很丰富的变化。我当即用打印机自制了卡片(普通 A4 纸加 9 个卡套)试玩,果然其乐无穷。尤其是高难度模式颇有挑战。进一步探索,我发现这个游戏还有一个商业版本,添加了更长的战役。当即在淘宝上下了单(有中文版本)。

从这个游戏开始,我了解到了 9 卡微型 PnP 游戏设计大赛。从 2008 年开始,在 bgg (boardgamegeek) 上每年都会举办 PnP 游戏设计大赛。这类游戏不限于单人模式,但显然单人可玩的游戏比例更高。毕竟比赛结果是由玩家票选出来,而单人游戏的试玩成本更低,会有更多玩家尝试。据我观察,历年比赛中,单人游戏可占一半。近几年甚至分拆出来单人游戏和双人游戏,多人游戏不同的设计比赛。

根据使用道具的限制条件,比赛又被细分。从 2016 年开始,开始有专门的 9 卡设计大赛。这是众多比赛中比较热门的一个。我想这是因为 9 张卡片刚好可以排版在一张 A4 纸上,只需要双面打印然后切开就完成了 DIY 制作。加上每个桌游玩家都有的少许米宝和骰子,阅读完说明书就可以游戏了。

如果嫌自己 DIY 麻烦或做出来的卡片不好看,在淘宝上有商家专门收集历年比赛中的优秀作品印出来卖,价格也非常实惠。比赛作品中特别优秀的,也会再完善和充实规则,制作大型的商业版本。例如前面介绍的坠空之下就是一例。我觉得,阅读规则书本身也很有意思。不要只看获奖作品,因为评奖只是少量活跃玩家的票选结果,每个玩家口味不同,你会有自己的喜好。而且我作为研究目的,更爱发现不同创作者的有趣灵感。

如果对这个比赛有兴趣,可以以关键词 2025 9-Card Nanogame Print and Play Design Contest 搜索今年的比赛历程。

我花了几周时间玩了大量的 9 卡桌游。喜欢的非常多,无法一一推荐。除了前面提到的坠空之下,让我推荐的话,我会选择 2023 年的 Survival Park (Dinosaurs game) 。倒不是我自己特别偏爱这款,而是我介绍给云豆后,他也很喜欢。

其实,除了 9 卡游戏,还有 18 卡,54 卡等。卡片数量限制提高后,设计者可以设计出更丰富的玩法。例如著名的 Sprawlopolis (无限都市) 一开始就是一款 18 卡桌游,但后来已经出了相当多的扩展。反过来,也有用更少卡片来设计游戏。比如 1 卡设计大赛就限制设计者只使用一张卡片(的正反面)。


在 bgg 上,你可以在 Design Contests 论坛找到每年举办的各种类型设计大赛。除了传统的 各种 PnP 类型外,我很喜欢的还有传统扑克设计比赛。用 2025 Traditional Deck Game Design Contest 就可以搜索到今年的。这个比赛开始的比较晚,2022 年才开始的第一届。

这个比赛限制设计者围绕传统扑克牌来设计游戏玩法。如果你想玩这些游戏,成本比 PnP 游戏更低:你甚至不需要 DIY 卡片,家中找出 1/2 副扑克就可以玩了。我小时候(1980 年代)特别着迷扑克的各种玩法,在书店买到过一本讲解单人扑克玩法的书,把上面介绍的游戏玩了个遍。所以在多年之后见到 Windows 后,对纸牌游戏的玩法相当亲切。

可以说扑克发展了几百年,单人玩法就没太脱离过“接龙”;多人玩法的核心规则也只有吃墩(桥牌)、爬梯(斗地主)、扑克(Poker 一词在英文中特指德州扑克)等少量原型。

但自从有了这种比赛,设计者的灵感相互碰撞,近几年就涌现出大量依托扑克做道具的新玩法。往往是头一年有人想出一个有趣的点子,后一年就被更多设计者发扬光大。电脑上 2024 年颇为好评的小丑牌也是依托德州扑克的核心玩法,不知道是否受过这个系列比赛作品的启发,但小丑牌的确又启发了这两年的诸多作品:例如我玩过的 River Rats 就特别有小丑牌的味道,同时兼备桌游的趣味。

单人谜题类中,我特别喜欢 2024 年的 Cardbury :它颇有挑战,完成游戏的成功率不太高,但单局游戏时间较短,输了后很容易产生再来一盘的冲动。

多人游戏,我向身边朋友推广比较顺利的有 Chowdah 。它结合了拉米和麻将的玩法。我只需要向朋友介绍这是一款使用扑克牌玩的麻将,就能勾起很多不玩桌游的人的兴趣。而玩起来真的有打麻将的感觉,具备一定的策略深度。

我自己曾经想过怎样用传统扑克来模仿一些经典的卡片类桌游,但设计出来总是不尽人意。比如说多年前我很喜欢的 Condottiere 佣兵队长,如果你没玩过它的话,一定也听过或玩过猎魔人 3 中的 Gwent 昆特牌。昆特牌几乎就沿用了佣兵队长的核心规则。而 2024 年的 Commitment 相当成功的还原了佣兵队长的游戏体验。

还有 MOLE 则很好的发展了 Battle Line 。

如果想体验用扑克牌玩出 RPG 的感觉,可以试试 2022 年的Kni54ts :有探索地图、打怪升级捡装备等元素;多人对抗的则有 Pack kingdoms

有趣的游戏规则还有很多,我自己就记了上千行规则笔记。这里就不再一一列出给出评价,有兴趣的同学可以自己探索。

深远未来开发总结

2025-10-05 15:06:12

桌游 Deep Future(深远未来)开发告一段落,我为它创建了一个 itch.io 的页面 发布第一个试玩版本。接下来的 bugfix 会在 github 继续,等积累一定更新后再发布下一个小版本。

这是一个兴趣驱动的项目。正如上一篇 blog 中写到,驱使我写它的一大动力是在实践中探索游戏开发的难题。写这么一篇总结就是非常必要的了。

开发过程

我在 2025 年 7 月底写下了项目的第一行代码。在前三周并没有在实现游戏方面有太多进展。一开始的工作主要在思考实现这么一个游戏,底层需要怎样的支持。我使用的引擎 soluna 也只是一个雏形,只提供非常基础的功能。我想这样一个卡牌向桌游数字化程序,更好的文本排版功能比图形化支持更为迫切。固然,我可以先做一个 UI 编辑器,但那更适合和美术合作使用。而我现在只有一个开发者,应该用更适合自己的开发工具。应该更多考虑自己开发时的顺手,这样才能让开发过程保持好心情,这样项目才可能做完。所以我选择用结构化文本描述界面:容易在文本编辑器内编写,方便修改,易于跟踪变更和版本维护。

在 8 月的前两周,开发工作更多倾向于 soluna

  1. 维护 yoga 的集成,编写布局模块。
  2. 增加文本块的支持,支持简单的文字排版。
  3. 增加 icon 的支持,可以和文本混合排版。
  4. 增加单色无贴图矩形。这样可以视觉化布局的 box 。
  5. 增加嵌套图层,而不是之前的平坦化图元。

期间有两个和游戏无关的(看起来很小的)问题花掉了我很多时间:

  1. 设置窗口标题 api 的多线程竞争引起的死锁
  2. 我按照过去的习惯,使用预乘 alpha 的方式处理半透明混合。后将其修改为非预乘模式。

关于 alpha 混合这点。根源在于 20 多年前我使用 CPU 计算 alpha 混合。当时如果将图片像素预先乘上一次 alpha ,可以减少一点运行时的 CPU 开销。这个习惯我一直带到现在 GPU 时代,本以为只是现代图形管线中的一个设置而已。当我独立开发时才发现,现在的图片处理软件默认都不会预乘方式导出图片,这让我自己使用 gimp 编辑带 alpha 通道的图片时,工作流都多了一步。因为 gimp 也是现学的,一下子也没有找到特别方便的方法给图片预乘 alpha ;使用 imagemagick 用命令行处理虽不算麻烦,但增加了工作流的负担。我在上面花掉了十多个小时后(主要花在学习 gimp 和 imagemaick 的用法)才醒悟,配合已有成熟工具简化开发工作流才是最适合我这样独立开发。所以我把引擎中的默认行为改成了非预乘 alpha 。

到 8 月的第三周,已经可以拼出静态的游戏界面:有棋盘、卡片、带文字的桌面布局。虽然从外观上,只是实现一个简陋的静态的带图层排版系统,但视觉上感觉游戏已经有点样子了,而不再仅仅是脑补的画面,这让开发的心情大好。

同时,我实现了基本的本地化模块。其实不仅仅是本地化需求,即使是单一语言,这种重规则的桌游,在描述暂时游戏规则时也非常依赖文本拼接。因为维护了多年 stellaris 的中文 mod ,我受 Paradox 的影响很深。早就想自己设计一套本地化方案了,这次得以如愿。

接下来的四周游戏开发速度很快。在之前三周的引擎补完过程中,我在脑中已经大致计划好后续的游戏开发流程:按游戏规则流程次序,拆分为布局阶段、开始阶段、行动阶段、结算阶段、胜利阶段、文明及奇迹分开实现。每个流程在实现时根据需要再完善引擎以及游戏底层设施。

以游戏玩的流程来依次实现,可以让游戏逐步从静态画面变成可交互的,这种体验能提供一种开发的目标感:让我觉得开发进度在不断推进;而每个步骤其实要解决和补充底层设施的不同方面,解决问题是不一样的,这样可以缓解开发的枯燥感。保持开发的心情最重要,这是我近二十年学到的东西。只是过去我一直偏重于底层开发,一直回避了相对枯燥繁琐重复的游戏功能开发。开发游戏中的不确定性,实现一点点交互功能也要花费大量时间的确是非常打击开发心情的东西。这并不像底层开发有一个确定目标,和 API 打交道(而不是纠缠在低效的人机交互中)也可以直指问题本身。


实现游戏布局设置时,我顺道完善了桌面布局模块,让棋盘、手牌、胜利轨道、中立卡牌区等展示得好看一些。并增加了和鼠标的交互,卡片在区域间的运动等。

待到游戏有了基本的交互,变得可以“玩”了。我发现,一个数字化的桌游最重要的是引导玩家熟悉桌游规则。重要不是做一个详尽的手把手一二三的教学,而是玩家一开始无意识操作中给出有价值的信息反馈。玩家可以学到每个操作对游戏状态造成了怎样的影响。玩家还应该可以随时点击桌面元素去了解这各东西是什么。虽然大段的文字描述对电子游戏玩家来说并不友好,但对桌游玩家来说是必修课。数字版能提供更好的交互手段,让玩家可以悬停或点击一个元素,获得文本解释,已经比阅读桌游说明书友好太多了。

所以我在一开始就花时间在底层设计了这样的提示系统。

打算往下实现更复杂的游戏流程时,我意识到这类流程复杂的游戏,主框架镶嵌一个简单的游戏主循环显然不够用。我需要一个状态机来管理交互状态。一开始并不需要将状态机实现得面面俱到,有个简单的架子即可。Lua 的 coroutine 是实现这样的状态管理非常舒适的工具,几十行代码就够了:gameplay 的状态切换很轻松的就和渲染循环分离开了。


游戏的“开始阶段”本身并无太多特有的 gameplay 需要实现。但是,这套游戏规则中最复杂的 advancement effect 机制在这个阶段就有体现。最难的部分是设计 advancement 的交互。在桌游原版规则中,玩家可以任意指定触发哪些 advancement ,它们的来源也很多样:弃掉手牌、母星区卡片的持久能力、殖民地区卡片的一次性能力等等。每张卡片上可触发的 advancement 数量不一,从 0 到 3 皆有可能,玩家可以选择触发或忽略,最后还可以自由决定对应 effect 的执行次序。

对于桌游来说,这是非常自然的形式:桌游玩家的脑海中一开始就包含了所有游戏规则,大脑会将这些散布在桌面各处的元素聚集起来,筛选出需要的信息,排序,执行,一气呵成。但对于数字版,很容易变成冗长的人机交互过程。每个步骤都需要和玩家确认,因为轻微的差别都有可能影响 effect 结算的效果。这不光实现繁琐、对玩家更是累赘。

所以,电子游戏中更倾向于自动结算的规则,减少玩家的决定权。玩家也不需要了解所有的游戏规则。只有在玩家成长中,从新手到老鸟的过程,玩家可能去关注这些自动结算是怎样进行的,电子规则则提供一些中途干预的手段帮助高级玩家,所谓提高玩法深度。以万智牌和炉石传说相比较,就能体会到内核相似但卡牌效果结算方式的巨大差异。前者是为桌面设计的,后者则天生于电脑上。

不过这次,我不打算对桌游规则做任何调整。专心实现桌游的数字化。有些看似有绝对最佳选择的 advancement ,我也没有让系统自动结算,还是交给玩家决定。一是复原桌游的游戏感觉,二是让玩家参与结算推演的过程,让玩家逐步熟悉游戏规则。

当然,一个 advancement 当下是否可用,这是由严格规则约束的。桌游中需要玩家自己判定(也非常容易玩出村规),而数字版则可以做严格检查,节省玩家记忆规则细节的负担。这个负担依旧存在,转嫁到数字版开发者身上了。为此我在桌游论坛和桌游规则作者探讨了多处细节,以在实现中确定。

advancement 结算这块一开始就决定仔细抽象好,这样可以复用到后续的行动结算部分。不过我还是低估了一次设计好的难度,后来又重构了一次。

另外,从这里开始,我发现这个游戏的规则细节太多以至于我必须提升测试玩法过程的效率。所以设计了一个测试模块。并不是一个自动化测试,而仅仅是为人工测试做好 setup ,不必每次从头游戏。我有两个方案:其一是写一个简单的脚本描述测试用的 setup ;其二是完善存档模块,及作弊模块,玩到一个特定状态就存档,利用存档来促使。

我选择了方案一,在编辑器里编写 setup 脚本。以我的经历,似乎很多游戏项目偏向于方案二,好像有纯设计人员(策划)和测试人员共同参与开发的项目更喜欢那样。他们讨厌写脚本。


行动阶段的开发基本可以按游戏规则中定义的 8 种行动分别实现。其中 EVOKE 涉及文明卡,第一局游戏中不会出现,本着能省则省早日让游戏可玩的想法,我一开始就没打算实现。

而 PLAN 行动是最简单的:只是让玩家创造一张特定卡片,而且不会触发 advancement 。我就从这里开始热身。不过,PLAN 行动和其它行动不同,它不是靠丢弃手牌触发的,而是一个专有行动。这似乎必须引入一种非点击卡片的交互手段。我就分出时间来给界面实现了 button 这个基础特性。同时确定了这个游戏的基本交互手段:当玩家需要做出选择时,使用多张卡片标注上选项,让玩家选择卡片;而不是使用一个文本选择菜单。交互围绕卡片做选择,一是我像偷懒不做选择菜单,二是希望突出游戏以卡牌为主体的玩法。当然,也需要多做一些底层设施上的工作:一开始我打算让每张卡片都在游戏中有唯一确定的实体,但既然卡片本身又可以用来提供玩家决策的选项,就在底层增加了一种叫做卡片副本的对象。

某些行动有独自的额外需求。像 POWER 行动最为简单,只是抽牌而已。但 SETTLE 就涉及和中立区卡牌的交互;GROW 涉及在版图上添加 token ;ADVANCE 涉及科技卡的生成;BATTLE 和 EXPAND 涉及版图区域管理。这些需求一开始在 gameplay 底层都没有实现,只在碰到时添加。

好在这些需求天生就可以分拆。我大致保持一天实现一个行动的节奏,像和银河地图的交互也会单独拿出一天来实现。让每天工作结束时游戏可以玩的部分更多一点,可以自己玩玩,录个短视频上传到 twitter 上展示一下。

部分复杂的部分很快经历了重构。例如星图的管理,从粗糙到完善。一开始只是对付一下,随着更丰富的需求出现很快就无法应对,只能重新实现。中间我花了一天复习六边形棋盘的处理方法

交互部分给图形底层也提出了新的需求:我给 soluna 增加了蒙版的支持,用来绘制彩色的卡片。


按我的预想,按部就班的把游戏行动逐步做完,游戏的框架就被勾勒出来了。让游戏内容一步步丰富起来会是我完成这个项目的动力。这个过程耗时大约 2 周,代码产出速度非常快,但也略微枯燥。感觉代码信息密度比较低,往往用打算代码实现一点点功能。这种信息密度低的代码很容易消耗掉开发热情。但它们又很容易出错,因为原本的桌游规则细节就很繁杂,一不小心就会漏掉边界处理。gameplay 的测试也不那么方便,如果依赖人去补充详尽的测试案例,开发周期会成倍的增加,恐怕不等我实现周全,精神上就不想干了。期间主要还是靠脑中预演,只在必要时(感觉一次会做不到位)才补充一个测试案例。

按节奏做到回合结算阶段时,我发现虽然看起来游戏可以玩了,工作其实还有很多:结算的交互和前面的差别很大、还需要补充 upkeep 方块的实现。回合结算事实上是系统行动阶段,虽然玩家参与的交互变少了,但自动演绎的东西增加了。系统结算过程可能导致玩家失败,所以必须再实现一个玩家失败的清点流程才能完整。

到 9 月初的时候,我完成了以上的工作。正好碰上每年一度的 indieplay 评审工作(四天),我在评审前一晚完成了第一个可玩版本(只有失败结算,没有胜利结算),第二天带到评审处,给一个评委试玩。这是第一个除我之外的玩家,表现还不错,居然只碰到 1 个中断游戏的 bug ,还只是发生在游戏最后一回合。来自于专业玩家的评价是:这么复杂的游戏规则想想也知道需要大量的开发工作,一个月就实现出来算是挺快了。缺点是部分游戏流程进行的太快,还没明白是什么就过去了(自动推演的部分);影响玩家选择的支付和结算部分,非常容易误操作选择对玩家不利的选择;至于最后碰到的那个 bug ,可以充分理解。第一次玩家试玩有一两个 bug 是再正常不过了。

我记录了一下玩家反馈,回家调整了对应部分:在底层增加了鼠标长按确认的防呆操作(其中图形底层实现了环形进度条的显示,同时发掘了底层图元装箱的一个小问题:图元拼接在整张贴图上时,需要空出一像素的边界,否则会相互影响),顺便重构了鼠标消息的底层管理 。另外,丰富了不少自动推演的流程的视觉表现,一开始设想尽量快速自动结算方便玩家恐怕不太合适。玩家并不需要过于加快游戏节奏,通过视觉上的推演过程让玩家理解游戏更重要。这些工作做了几个晚上(白天需要做游戏评审工作)。


接下来的两周发生了意外,我的开发工作几乎停滞。

租的房子到期,原计划在 9 月底搬家。给 indieplay 做评委的最后一天,接到母亲电话,在小区门口被顺丰快递员骑的电瓶车撞到,胫骨粉碎性骨折住院。处理医院的事情花了一整个晚上,心情大受影响。

调整心情后,我还需要面对另一个问题:原本计划是和母亲一起收纳搬家的东西,时间上非常充裕,每天只需要规划出小块时间即可。现在虽然父亲可以负责医院住院的事情,但搬家的工作几乎得我一个人来做了。关键是意外让日程安排突然变得紧张起来。把开发从日程重去掉是必然的。

最终如期搬完家,非常疲惫不堪。万幸母亲手术很顺利,只是未来半年行动受限,需要人照顾。


这段时间,我已经把游戏代码仓库开放。由于在 twitter 上的传播,已经有少量程序员玩家了。其实游戏并未完成,网友 Xhacker 率先赢得了(除我本人的)第一场游戏胜利…… 只是代码上并无胜利判定,所以他补完了这部分代码。由于对游戏规则不那么熟悉,所以实现是有 bug 的,后来也被我重构掉了。但接受这种 gameplay 的 PR 让我感受到了玩家共同创作的热情。

Xhacker 同学还依照本地化格式,提供了英文版本的文本。这也是我计划中想做而没精力顾及的部分。帮我节省了大量的时间。后来我只花了两天时间就将后续开发中的新词条双语同步。

网友 Hanchin Hsieh 对多平台支持表现出热情。先后实现了 soluna 的 MacOS 和 Linux 版本 。中间我也花了 1-2 天时间解决多平台的技术问题。还给 soluna 提交了 CI 以及 luamake 的构建脚本。

如果开源项目可以拆分出更独立的子任务(例如跨平台支持、本地化等),多人合作的确能大大缩短开发进程。


搬家结束后,我重拾开发。恢复开发状态用了一两天的时间。后续工作主要是以下几点:

  1. 重构胜利结算流程
  2. 完善游戏存档
  3. 实现文明卡和奇迹
  4. 制作主界面,增加多游戏文件支持和语言切换的交互(之前只提供命令行切换)
  5. 卡片随机命名
  6. 实现输入控件以及玩家自定义卡片名称
  7. 将玩家游戏过程组织成文本供回顾游戏历史

这部分花了两周时间,可以说是按部就班。但实际开发工作比字面上的需求多许多。

在重构胜利结算流程中,我顺手把控制游戏流程的状态机模块修改了不少。因为无论是胜利结算还是失败判定,以及持久化支持,都会引入更复杂的状态切换。需要保证这些切换过程中数据和表现一致不能出错。

胜利结算中,为了增加游戏的仪式感,底层支持了镜头控制:可以聚焦放大桌面,将镜头拉近和恢复。

游戏存档被分离到独立服务中,同时承担数据一致性校验。在过去一个月的开发中,我发现存档对最终 bug 非常有效。依赖出错前的存档恢复出错环境比查看 log 要方便得多。但这需要更细致的存档备份,方便玩家从文件系统中提取历史存档。同期还解决了一个底层在 windows 上处理 utf-8 文件名转换的 bug 。

文明卡和奇迹都是游戏后期内容,开发起来并不容易。对于文明卡,规则书上有不少含糊其辞的地方,专门去 bgg 论坛和原作者核对细节。文明卡有一半的每个效果是特殊的,需要单独实现。但这些单独实现的部分和之前的 advancement effect 又有部分共同之处。本着让日后修改更容易的原则,再次重构了部分 advancement 处理的代码,让它们可以共用相同的部分;而奇迹的实现使得星图的管理模块又需要扩展,同样需要扩展的是 EXPAND 行动流程。这部分的开发持续了好几天。

主界面功能涉及到多层界面布局。之前游戏只使用了单层 HUD 结构外加一个说明文字的附加层,没打算实现多层界面(即多窗口)。而按钮模块也是临时凑上去的。待到实现主界面时,其结构的复杂度已经不允许我继续凑合了。

所以我对此进行的重新设计:原则上还是将行为了表现以及交互分离。在同一个代码文件里实现了所有界面按钮对应的功能,用一个简单的 table 描述界面按钮的视觉结构,通过这个视觉结构表来显示界面。这还是一个贴近这个游戏的设计,不太有普适性。可能换个游戏的交互风格就需要再重构一次。不够我觉得现阶段不用太考虑以后再开发新游戏的需求。遇到重写就好了。多做几次才好提取出通用性来。

界面的防呆设计没有继续沿用长按转圈的方法,而使用了两次确认的方式。即危险操作(例如删除存档)的按钮在点击后,再展开一个二级菜单,需要玩家再点击新按钮才生效。我觉得这种方式实现简单,也可以充分防呆。

我原本只想做卡片随机命名,不想做玩家输入自定义卡片名称的。一是做了一个多月有点疲倦了,想早点告一段落;二是考虑到我自己玩了不少策略游戏,几乎不会修改系统随机起好的名字,即使游戏提供了玩家修改名字的功能。很快的,我就从网上搜集了中国和世界大城市名称列表,用来随机给星区和星球卡命名。但当我在做科技卡命名时却犯了难。在原本桌游规则里,依照三条随机组合的 advancement 效果给科技卡起一个恰当的名字,是玩家玩这个游戏的一大乐趣(也是一项对玩家想象力的挑战)。程序化命名无非是在前缀、后缀、核心词的列表中按规则组合,必然失去韵味。我花了一天时间做了一班并不满意。尤其是想同时照顾中文和英文的命名风格太不容易。

Paradox 在群星的最近版本中一只致力于生成更好的随机组合名称。我在汉化时也学到了不少,这或许是很好的一个课题,值得专门研究实现。但在当下,我只想早点发布游戏。所以,我选择实现键盘输入模块。

soluna 原本并未实现键盘输入的相关功能。这主要涉及文本块排版模块的改进。因为一旦需要实现输入,就必须控制输入光标的位置,这个信息只要文本排版模块内部才有。我原计划是在日后增加文本超链接功能时再大改排版模块的,这次增加输入光标支持只能先应付一下了。有了底层支持,增加用户自定义卡片名称倒很容易。

至此,我已经完成了绝大部分预想的游戏功能。除了 7 ,暂时还未实现。

最后,还差一个 credits 列表。之前在做网游,只有在大话西游中我按当时的游戏软件管理加上了制作人员名单。那还是我在客户端压(光)盘前一晚执意加上的。为此我熬了一个通宵。后来的网游我便不再坚持,这似乎开了一个坏头,整个中国的网游产品都不再加入 Credits 了。再后来,我只在杭州开发的一个并不成功的卡牌游戏(卡牌对决)中再加过一次 credits。

正如《程序员修炼之道》第二版所言:

提示 97 :在作品上签名

“保持匿名会滋生粗心、错误、懒惰和糟糕的代码,特别是在大型项目中——很容易把自己看成只是大齿轮上的一个小齿,在无休止的工作汇报中制造蹩脚的借口,而不是写出好的代码……我们想看到你对所有权引以为豪——这是我写的,我与我的作品同在。你的签名应该被认为是质量的标志。人们应该在一段代码上看到你的名字,并对它是可靠的、编写良好的、经过测试的、文档化的充满期许。这是一件非常专业的工作,出自专业人士之手”。

我在 gameplay 的开发中充满着仓促、粗糙的设计,在游戏中展示我的名字会让我心存愧疚,以后或许会完善它或在新作品中做得更好。


数据统计

开发这个项目,我经历了接近两个月,从 2025 年 7 月底到 2025 年 9 月底,除去中间被打断的两周,一共 7 周时间。

游戏项目一共增加了 25152 行,删除了 7912 行文本,合计 17240 行。其中包含了 1000 多行的本地化文本和 3000 行左右的界面布局、测试数据、规则表格等。实际代码在 13000 行左右。

引擎 soluna 因这个游戏增加了 7756 行,删除了 2,084 行代码,合计 5672 行代码。

虽然游戏中美术量不大,但我还是大约花了 3-4 个工作日制作所用到的美术资源。时间花在学习 GIMP 和其它一些美术制作工作使用上为主。图标是在 fontawesome 上进行的二次创作,卡片是自己绘制的。星图则直接复用了桌游的原始资源。

版面设计不算复杂,yoga 提供的 flexbox 方案很好用。算上学习 flexbox 排版的时间,前后大约花了 2 个工作日。

虽然游戏规则很繁杂,但 bug 比我预想的少。debug 时间不算太多,通常随着开发就一起完成了。后来在 github 上玩家提到的 bug 也都可以马上解决。预计后续的游戏测试过程会是一个长尾,持续很长时间,但需要的精力并不多。不过能做到这一点,得益于桌游规则经历了近十年的修订,非常稳定。而我在动手实现数字版前,已经花了一个月的时间充分玩了实体,经历了无数的游戏规则错误。动手写代码时,游戏规则细节已经清晰的刻画在大脑中了。这和写底层代码很像:需求已经被反复提炼,只需要用代码表达出来。开发过程解决的都是程序结构问题,而不是应对多变的需求。

经验积累

经历这么一次,我想我可以部分回答项目开始之初的疑问。

我在开发过程中的情绪波动告诉我,最重要的是保持开发热情。不同情绪状态下的效率、质量差异很很大。这可以部分解释为什么行百里路半九十。并不是最后 10% 真的有一半工作量,而是开发热情下降后,开发效率变低了。伴随着潜在的质量下降,花在重构、debug 上的时间也会增加。

所以明确拆分任务真的很重要。每完成一步就解决了一个小问题。开发精力就能回复一点。但这样也容易陷入到开发细节中。多人协作可以一定程度的避免这一点,分工让人更专心。一人做多个层次不同门类的工作需要承担思维切换的成本,但能减少沟通,利弊还说不好。

对于游戏来说,视觉反馈是激励开发热情的重要途径。所以需要做一点玩一点。让自己觉得游戏又丰富了。但是追求快速的视觉反馈很容易对质量妥协:我先对付一下让游戏跑起来,不惜使用冗长重复的实现方式,硬编码游戏规则…… 事后一定需要额外精力去拆这些脚手架的。所以,会有很大部分的功能需要实现两遍。这或许是预估开发时间时需要将时间乘 2 的根源。

虽然重构很花时间,有时候还很累。但过去的经验告诉我,越早做,越频繁,越省事。这也是独立开发的优势之一:你不必顾及重构对合作者的冲击。所有东西都在一个人的脑子里,只要对自己负责就够了。

对于独立开发,代码量又变成了一个对项目进度很好的衡量标准。因为你知道自己不会故意堆砌低效代码,那么每 100 行代码就真的代表着大致相同的工作进展。整个游戏的核心代码放在 20000 行之内是非常恰当的篇幅,其实这个数字对非游戏项目也适用。因为这意味着你可以在一到两个月完成项目的基础工作。这样的周期不至于让热情消磨殆尽。后续的长期维护则是另一项工作了。

要把代码量控制在这个规模,需要尽可能的把数据分离出去。不然游戏很容易膨胀到几十万行代码。识别出哪些部分的代码可以数据化只能靠经验积累。而经验来源于多做游戏。所以,我今后还需要多写。

同时,分离引擎也是控制游戏代码规模的要点。不用刻意做游戏引擎,只需要做游戏就够了。识别出通用部分,集成到引擎中。给游戏项目也留一个底层模块,把不确定是否应该放在引擎中的代码先放在那里。它们可能跨不同游戏类型通用,但只是还没想到更好的抽象接口而已。

“优化”工作对我很有吸引力。但考虑到游戏开发进度,可以先把优化点记录下来放一放。保持着写好代码的决心,晚一点做优化不迟。这里的优化不仅仅指性能优化,也包括更好的代码结构和更紧凑的实现方式(更短的代码)。老实说,目前这版实现中,还是有大量冗长可以改进的代码,我相信有机会再做一次的话,我只需要一半的代码就能实现相同的功能。当然我不需要再实现一次,开始下一个项目更好。

开源依然有很大的优势。虽然很少有游戏业务代码开源,《大教堂与集市》的 4.10 探讨了“何时开放,何时关闭”,游戏业务本身开源能获得经济收益非常少。我一开始也考虑过闭源开发。这并不是一个经济上的决定,而是我知道,这个项目注定不会有很高的代码质量,低质量代码不具备传播因素,它无法作为学习参考,也没什么复用性,反而有一点点“面子”问题,毕竟写出低质量代码“面子上”不太好看。

但我发现这个项目开源后依然获得了额外收益。

吸引了程序员的参与,多交了几个朋友。在发现 bug 时,有一个更好的交流基础,对着代码看更清晰。即便只是自己读,阅读公开代码比阅读私人代码会更仔细。而且更有动力用文字解释。在写作过程中,思路得以迅速理清。

btw, github 的公开仓库比私有仓库有更多的免费特性。

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

2025-09-16 13:05:44

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

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

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

2025-09-09 17:26:04

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

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

之前的问题出在哪里?

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

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

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

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

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

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

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

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

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

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

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

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

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

2025-08-26 08:37:50

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