« August 2019 | Main | October 2019 »

September 23, 2019

群星的经济系统

群星是个非常复杂的战略游戏。我玩了 3 年多,600 多小时。这三年一直在跟进中文化工作,校对及翻译了几十万字的文本,可以说是非常喜爱了。

如果有机会,我希望日后可以制作一款类似的战略游戏。我认为我应该把群星的系统好好整理成文字,这样帮助自己看出它的设计思路。群星采用了一种独特的商业模式,就是不断地积累忠实玩家,然后按周期推出收费的新玩法,让玩家持续付费。不用于常规的售卖游戏内容,它是在售卖游戏玩法。现在的版本和 3 年前推出的版本,可以说是在玩法上有天翻地覆的变化。

这样,玩家其实在花钱购买新的体验,而创作者则一步步的完善游戏系统,不用太担心一下次引入过多的系统导致玩家学习曲线过高,也不用担心有设计失误:总可以在下个版本修改。而玩家也比较放心,只要持续付费,跟着设计者的脚步走,总有新奇的享受,还免去了玩新游戏从头学习的成本。

群星的整个系统太复杂了,远非一两篇 blog 所能写清楚的。我想先记录一下 2.3 版本的经济系统,是俗称“种田”玩法中的重要部分。其实,群星有个官方的 wiki ,讲解的非常清楚 。我只是根据自己玩游戏过程的理解,重新整理一下而已。

我玩过的最早的战略游戏当属三国志系列,一开始就有所谓的内政系统。但是它仅仅只是简单的用武将提升一下城市的繁荣度的数字而已。虽然已经有人口、金钱、粮草的不同维度,但相互影响非常简单。数值发展的空间也只跟招募到的将领能力和原本设定好的城市基础值有关系。内政系统不太可能成为独立的游戏乐趣来源。

而从文明系列开始,经济发展就变得错综复杂,过于简单的经济设定已经无法满足战略游戏的设计需求了。这类战略游戏的玩家不满足于一个指令下去,某个维度的数值就提升起来,这种简单直白的系统。不同维度的数值间相互制约,玩家根据每局游戏随机出来的环境、对手,动态的调整策略,在多种制约下找到最高效率的发展路径,成了游戏重要的乐趣来源。

群星和文明不同,它没有采用分时回合制,而采用了即时带暂停的模式。但本质上还是一种同步回合制。所有玩家/非玩家都是同时进行行动的,主动指挥的单位行动以天(游戏中最小的时间单位)为回合推演,而行动对经济系统的影响则以月为周期结算。我认为两种模式各有优劣,但我更喜欢即时带暂停的模式,它也更适合在网络上对战。


群星有三种基础资源:能源点、矿物、食物。

因为游戏背景需要,没有设定钱(货币)这个东西。但其实,能源点就相当于同类游戏中的钱。在早期版本还不那么明显(也可能一开始并不想做此等价),但 2.x 版以后,把食物从特别的本地资源提升为和能源点、矿物一样的可以全局储藏和交易的资源,并加入了交易市场的系统,能源点就明确的定义为货币了。在市场上,你可以自由的把其它资源和能源点相互兑换。只是兑换率有所浮动。

能源点本身的价值在于维持建筑、舰船、太空站、机器人。尤其是舰船,在港和离港的维持开销相差巨大,战争时期是一笔巨大的开销。另外,它还用于一些一次性支付的费用:例如招聘领袖,清理区块,启动殖民及地貌改造,建造机器人等等。这些单次开销导致玩家需要积累大量的能源点备用。

矿物则用于建造空间站和地表建筑,这影响着玩家帝国的发展。早期版本还用于维持舰队,后来这部分职能被转移到二级资源上去了。同时,矿物变成了二级资源的原料,我认为这个修改增加了经济系统的深度。

食物用于维持人口。根据玩家自己选择的政策,还会影响人口的幸福度和增长率。积累食物除了用于应付食物短缺的风险之外,还能一次性(1000 )临时增加单个星球的人口增长率。群星还增加了不需要食物的玩法(机器政体),以及消耗人口(吃人)产生食物的玩法,并为之增加了一些相关的设定,本篇就不对此展开了。

趁现在系统还很简单,我先梳理一下最基础的关系:

矿物用于发展,发展可以扩大生产规模。生产行为受发展规模和人口规模双重制约,生产行为产生能源点,能源点用于维持生产规模,食物用于维持人口。

人口的管理是另一个巨大繁杂的系统。人口的道德观取向,人口组成的派系的诉求,深刻的影响着群星世界中的外交行为和政治行为。人口的幸福度,影响着星球的犯罪率和稳定度。控制战后的稳定度也是游戏中的重要环节。群星甚至有专门针对犯罪的玩法,和犯罪经济体系,但本篇就不对此展开。对于常规经济系统中,我们可以先简单的认为:需要控制住人口的幸福度就好了。

虽然,群星中资源产出可以完全不依赖人口,只需要扩张地盘,修无人空间站挖矿就可以了。但领土扩张的规模受到宝贵的影响力和管理规模的双重制约。大部分资源产出,还是依赖人口的。在游戏初期,人口通常是多多益善。但后期,我们需要为日益增长的人口规模考虑就业问题、住房问题、娱乐问题。

人口的生产方式是利用建筑所提供的职位。不同的职位提供不同的产出。食物、能源、矿物,这些初级资源都是凭空生产出来的。只需要在星球上开发对应的地区。不过地区的开发会增加管理规模,开发一个地区和开发一个新的星系相当。开发新星系带来的是星系中的资源,它们用无人空间站就可以开采。不同的星系带来的资源是随机的,这种随机性带来了玩家的决策点,同时星系的不同位置会影响帝国内聚性,它可能极大的影响管理规模;开发地区则可以带来固定类型的收入,玩家只需要安排人口工作即可。地区的收入会受地表其它建筑的加成,玩家自主性更强一些,只是人口管理由额外的负担。


接下来引入的是二级资源:合金和消费品。

合金用来制造和维持太空站和舰队,消费品用来维持人口的幸福度。它们作为普通资源,依旧可以在市场上自由兑换,只是成本更高。

消费品同时在特定建筑里转换为科技点,而科技发展则是群星这个游戏的重中之重。因为科技的发展可以极大的改善生产力、增强战斗力。当经济规模扩大,如果不提高生产力,维持成本就会覆盖掉规模扩大的收益。

科技点可以看成是一种抽象资源,它只有研究对应科技(或处理特殊科技事件)这种消耗方式,是按月进行的。虽然可以通过事件获得科技点并累积下来,但累积的科技点并不能直接支付换得科技研究进度,而仅仅是加快(双倍月产出的投入)研究进程。

科技点不应被视为三级资源(从消费品制造),因为它也可以通过开发新星系然后修空间站直接获取。无法在市场上交易。但它可以通过外交途径(科研协作)获取,这超出了本篇对经济系统的分析。

科技点之外还有两个抽象资源:凝聚力和影响力。

凝聚力是 2.x 加入的新元素,我认为它其实是一种固定科技树。群星最初的科技树只有随机抽取途径,也就是每局如何爬科技树是随机决定的,这个随机元素增加了变化,考验玩家的决策能力。而凝聚力则加回了传统策略游戏中固定科技树的玩法。玩家可以排除随机因素来决策。

凝聚力的来源比科技点更丰富一些。实现统治者承诺,事件奖励,高阶职位,贸易价值交换(依赖政策调整),太空基地模组,艺术学校,矩形结构,等等。和科技点不同的是,它很难通过单个星球的过度开发(精铺)来增加产量,而且也很难通过领袖能力来大幅度提高利用率。

影响力控制了帝国的规模发展。它的来源很多,但加成总量却很难提高。而且还有 1000 点的上限,无法增加。但开销却非常大:外交,战争,扩张版图,整合属国,变更国体,激活决策和法令,都需要消耗它。严格来说,影响力不应属于经济系统的一部分。

还有一个 2.x 后新加的系统叫做贸易价值。它无法被累积,但可以通过修建太空站来采集,并转换为能源点(或部分转换为凝聚力)。采集非殖民星球上的贸易价值需要武装太空站,维持部队保护贸易线路。这给纯种田玩法也需要维持武装一个理由。贸易路线系统让星系之间的拓扑关系也变得更能影响玩家的决策,而不像之前的版本,主要看版图块上的资源产量就够了。

贸易价值转换为经济价值非常困难,但是因为它本身不消耗人口,也不占用已有的资源,所以一旦成功维系,管理成本有很大的优势。


现在再总结一下。

影响力和管理规模限制了玩家的扩张速度和扩张规模。经济规模发展上去后,惩罚越来越严厉。所以,玩家必须把经济用于科技发展,以提高生产力来抵消惩罚带来的负面影响。

能源点维持着社会运作,人口维系着资源的生产,生产出来的食物和矿物转换的消费品又反过来支撑着人口。多余的生产力则转换为科技,用于提高社会的效率。玩家要做的是:不断提高生产规模,动态适应该规模下的维护成本,让维护成本最小化。在此同时,尽量多释放一些生产力出来,变成科技和战斗力。另外,庞大的战斗力还会成为限制玩家发展的负担。

这让我想到我很喜欢的一个桌面游戏:Power Grid : Factory Manager 。在游戏中,玩家需要不断地计算投入产出比。并不是无限的提高科技水平,提高机器的单位产能就是好的。理性的玩家在一起,往往经过精密的计算和博弈,会在最后几个回合放弃收购和安装非常有价值的高级机器。


最后,群星为了增加经济系统的深度,还设计了战略资源。准确说,战略资源很早就被引入游戏,直到后期的版本才演化为经济系统中不可缺少的一部分。

有三种战略资源:瓦斯,水晶和粉尘,可以说是高级资源。它们通过矿物生产出来,但需要额外的二级科技。虽然游戏的早期就可以在版图上找到可以直接开采的战略资源,但是开采它们依然需要二级科技。

除了用战略资源提高生产力外,还可以用来分别提高游戏中不同类别武器的战斗力。直接用能源点兑换,也只出现在游戏中后期开放星际市场后。

另有四种更高级的战略资源:卓气,暗物质,活金属,纳米材料,分别对应游戏中后期最高级的不同科技。我认为是给经济系统锦上添花的元素。它们也的确是后期 DLC 新玩法慢慢加入的。


群星中,资源短缺时的后果,我觉得也和很多别的游戏不同。比如食物短缺并不会直接让人口死亡,能源点短缺也不会让建筑直接停工,矿物短缺不会直接削减军队……

它只是简单的给玩家加上负面 buf 。能源短缺会导致削减矿产量,减少军队伤害,降低护盾生命,减少武器伤害;矿物短缺会导致合金产量下降,消费品下降, 机器人建造速度减慢;食物短缺会导致人口幸福度下降,成长率下降;合金短缺会导致舰船装甲下降,开火速度下降;消费品短缺导致幸福度下降,研究点减少,凝聚力产量下降;影响力短缺导致幸福度下降;战略资源短缺会导致所有资源产量下降。

这些 buf 的负面影响会间接的影响玩家经济。如果置之不理,连锁效应通常会导致经济系统的崩溃。这些影响是间接的,有一个过程,玩起来的感觉也颇为有趣。

September 16, 2019

场景层次结构的管理

今年上半年的时候,就想把我们游戏引擎中场景层次结构管理模块的设计记录一下。每次想写的时候都在做小调整。直到最近,算法和数据结构才稳定下来。今天做一个记录。

游戏里的场景对象,通常以树结构保存。这是因为,每个对象的空间状态,通常都受上一级的某个对象影响。

从管理角度讲,每个对象最好都能知道它可以影响其它哪些对象;且必须知道它被哪个对象影响。所以,这会用到一个典型的树结构。尤其在做编辑器时,树结构还会直接呈现在编辑界面上。不过,我认为在运行时,从父对象遍历到子对象的需求并不是必要的,需要时可以额外记录。从数据上考虑,父亲记住孩子和孩子记住父亲,是重复了同一种关系信息。如果不需要记住孩子的兄弟次序,那么在核心数据结构中,我们只需要让孩子记住父亲就足够了。

去掉冗余信息可以简化数据结构、减少维护成本、避免犯错误。尤其对于 ECS 架构,我希望所有对象都是平坦的,在场景对象组件上,一个 parent id 可以最少的构造出场景的层次结构出来。

每个可以容纳别的对象的对象,可以在自身拥有一系列的挂接点(一个相对于自身的变换矩阵)。对象都是挂接在另一个对象的挂接点上。挂接点是编辑时产生的静态数据,通常不允许在运行时改变。但对象可以改变自身相对挂接点的矩阵变换。例如,整个场景有一个根节点,所有运行时的对象都是挂接在这个根节点的默认挂接点(一个单位矩阵)上。这样,每个对象都是由一系列的 slot matrix + local matrix 决定最终的 world matrix 。

最终,我们用来描述对象在场景中的空间信息的组件 transform ,包含了这么一些属性:

  • world matrix :最终在世界空间中的矩阵
  • base matrix :父亲自身局部空间中的矩阵
  • scale :自身相对 base matrix 的缩放
  • rotation :自身相对 base matrix 的旋转
  • translation :自身相对 base matrix 的位移
  • parent id :受哪个对象的影响
  • slot name :挂接在哪个挂接点上

其中,world matrix 和 base matrix 都是运行时逐级计算出来的。scale rotation translation 均是可选项,默认就是相对于挂接点的原点:scale 为 1 ,rotation 为不旋转,translation 为 0 。parent id 和 slot name 决定了空间层次信息。

对于可以容纳和影响别的对象的对象,还会有一个编辑时产生的静态数据,记录了每个 slot name 对应的变换矩阵。如果是运行时需要动态调整相对父对象的位置,挂接点可以省略,默认为单位矩阵,相对变换由 SRT 决定;如果运行时固定位置,在编辑时就生成好,那么相对位置记录在 slots 结构中,对象只需要记录 slot name ,而省略 SRT 的值。

slot 的变换矩阵和对象自身的 SRT 分离,增加了这个结构的弹性。我们可以复用 slots 这份静态数据:例如在编辑时生成后层次结构的预制件。然后,运行时还可以通常对对象自身的 SRT 进行调整。去掉 slots 的设计可以进一步简化数据结构,但是,也失去了一些空间和时间上的优化空间,这点,后面会再谈谈。


这里没有记录每个对象的孩子,这给计算每个对象最终的 world matrix 造成了一定的困难。最为粗暴的方法是,渲染每个对象的时候,依据 parent id 和 slot name 向上回溯每级对象,把矩阵乘起来。不过,相对于渲染的高频率,其实修改场景中对象的 SRT 是相当低频的,每次修改的数量也远远少于场景对象的总数。为了低频低量的操作,每个渲染帧对所有的对象进行复杂的处理无疑是很浪费的。为了避免中间节点的重复计算,我们还需要把当前帧计算过的节点做上标记,这也增加了数据结构的复杂度,过多的标记判断是省不掉的开销。

传统上还有另一种方法减少遍历。那就是在修改一个对象时,将所有的孩子都置入更新集。那么,每帧只需要遍历受影响的对象。不过这依赖数据结构中有孩子的关联信息,我们这次的设计中没有,我也不想加上。况且,我不太喜欢在修改数据时,还有隐式的额外操作(标记孩子)。

我们现在的 ECS 框架有能力遍历出某个类型的组件,以及这类组件中当前帧被修改的子集。但,遍历无法保证次序。而在这个案例里,次序是有必要的。我希望父亲被先处理,因为孩子的空间信息需要利用父亲的计算结果。兄弟之间的次序则不相关。

所以,最直接的方法是先遍历再排序,再依次处理。传统的方法中,在对象中记录下所有的孩子,也正是为了方便确定处理次序。当没有这个直接信息时,我们还是可以做拓扑排序来达到一样的效果:简单的数据结构(只记录父结点) + 独立的算法步骤 (先排序,再遍历平坦的数组)。

我们用 ( parent , id ) 这样一对表达父子关系的 id 来表示一个待处理的对象,把它放入一个数组,可以表示出一组层次结构数据。对这样一组 id 对进行拓扑排序是一个纯粹的算法问题,用 Lua 实现很简单,也可以留到以后用 C 优化一遍。不过需要留意的是,拓扑排序并不算廉价,它的时间复杂度是 O(n + m) 。

为了减少 n 的规模,我们在排序阶段不处理场景中的所有叶子节点。叶子节点全部留到渲染阶段,在渲染前判断父亲的 world matrix 是否改变,来决定自身是否需要重新计算 world matrix 。

对于非叶子节点,我每帧采用如下的算法:

  1. 遍历所有被修改过的对象,这些均为一定需要重新计算 world matrix 的,置入更新集。
  2. 遍历所有未被修改过的对象,分别设于更新集、待更新集、不变集其中的一个。所有节点均有一个默认归属集合供子节点使用。根节点默认进入不变集,其余节点默认进入待更新集。遍历过程中,根据父节点归于父节点所在集合。即:父节点在不变集,自己就加入不变集;父节点在更新集,自己就加入更新集;父节点未确定,就进入父节点的默认归属集。
  3. 对待更新集进行一次拓扑排序。
  4. 遍历待更新集,依次检查其父亲是否在更新集内,如果在则把自身加入更新集。由于这次遍历是有序的,所以不会再有遗漏。
  5. 对最终的更新集做一次拓扑排序。

注意:这里进行了两次拓扑排序,可能有较大的开销,所以我们可以考虑进一步的优化:

  1. 将每帧最终的更新结果保存下来。这是一个排过序的结果。
  2. 上面的步骤 1 中,只有当修改过的对象不在上一帧的结果中,才需要做后续步骤。
  3. 如果略过了后续步骤,那么更新集使用上一帧保留的集合。但在处理时剔除当前帧不需要变更的对象,但设定一个对象数量阙值,保留一定的对象在这个集合中,不将集合完全清理干净。防止不同的帧交错修改不同的对象。

因为大多数情况下,我们只会修改固定的少量对象的空间信息,大多数对象在场景中都是固定的。所以,一旦易变的对象的处理次序被 cache 下来,接下来的几帧修改的对象都在相同的集合中,这个遍历次序也就被 cache 了下来。也就是说,大多数情况下,只需要做步骤 1 。

至此,我们得到了更新次序,可以循序计算每个对象的 world matrix 。如果当前帧没有重新计算过父亲,且父亲没有更换,那么 base matrix 就可以保持,只需要把 SRT 乘上去,得到 world matrix ;如果父亲在当前帧被重新计算,则需要根据 parent id 和 slot name 重新算出 base matrix 。


最后还有一点优化:

在编辑场景时,一组对象可能构成复杂的空间结构。一旦编辑好,我们可以为这组对象生成一个预制件,并标记为运行期层次结构不可修改。这是一种非常常见的情况。这样,对于预制件来说,内部可以是一颗复杂的有层次的树;但是对外,所有的插槽则是平坦化的。

对于预制件内部的层次树,由于数据是静态的,我们更容易用 C 的数据结构来储存,并用 C 库来做计算。这块,我挑选了成熟的开源骨骼动画库来实现,用和骨骼系统一致的数据结构来表达。然后,我们可以把它作为一个整体放在运行期的场景节点上。这样,运行期的场景树规模要小的多,放在 Lua 中的管理规模就小了很多。更适合用 Lua 弹性管理。