聊聊那些具有函数式编程感的游戏

作者:Moonad
2023-06-11
18 14 3

前言

“函数式编程”这几个组合在一起后略显陌生的字,可能会让许多人感到疏离。但本文并不是晦涩难懂的理论研究,也不是枯燥无味的编程教学,接下来的内容,主要是想聊聊函数式编程中的一些“形式美感”,以及它与游戏之间可能存在的关系。

函数式编程(Functional Programming),或称函数程序设计、泛函编程,是一种编程范式,它将电脑运算视为函数运算,并且避免使用程序状态以及可变对象。

诞生 50 多年后,函数式编程开始获得越来越多的关注。不仅最古老的函数式语言 Lisp 重获青春,新的函数式语言也层出不穷,比如 Erlang、clojure、Scala、F# 等等。除此之外,目前最当红的 Python、Ruby、Javascript,对函数式编程的支持都很强,就连老牌语言如面向对象的 Java、面向过程的 PHP,都忙不迭地加入对匿名函数的支持 [1] 。越来越多迹象表明,函数式编程已不仅是学术界的最爱,而已经开始在业界广泛实用:在统计分析数据、科学计算、大数据处理、web 开发、服务器脚本等领域,常常可以看到它的身影。

在笔者看来,函数式编程风格,是我们更多地去告诉计算机“要做什么”,而不是告诉计算机“到底怎样去做”,从而抽象了具体实现的各种细节。

比如,我定义了一个 h,并告知计算机,h 就是 gf 的组合。

h = g . f

下面的例子与上面例子有一点区别:我也定义了一个 h,不过,是通过一系列具有时间顺序的具体操作。

h x = let y = f x
      in g y

简单来讲,第一个例子所代表的函数式编程关心类型(代数结构)之间的关系,而第二个例子所代表的命令式编程关心解决问题的步骤。很明显,前者抽象了更多具体实现的细节,使用者可以更精准地去将编程的符号和自己的思想匹配,并且达到更简洁、更易懂、更方便扩展的效果。函数式编程的思维就是考虑如何将一些关系组合起来,逐步构造出你设计的程序。

《Domain Modeling Made Functional》的作者 Scott Wlaschin 用乐高的游戏体验来概括函数式编程的特点:到处都是组合。 

用函数式编程思想理解游戏结构

现在,想象一种编程语言——它通过底层封装,把互动的简单接口暴露给玩家,让玩家去决策“要做什么”,而不是“怎样去做”,这本身就很像游戏。

当玩家开始思考“要做什么”,并发现他能操作的“行为”存在形式上的一致性时(比如,这些命令可以相互组合、这些操作可以用同一种形式包装、这些行为有内部关联性等),类似“函数式编程”的体验感就会出现。

而面对这样的游戏,函数式编程的思想仿佛一面透镜,能使我们更深刻地观察到这种美感背后所蕴含的秩序。

《Noita》和《SquishCraft》

《Noita》(也译作《女巫》,下称《Noita》)以“添加形容词”的简单方法,实现了它的法术制造系统。该系统背后的复杂程度完全不亚于经典 Rougelike 游戏中的 Build,有种“四两拨千斤”的味道。

在日常书写文字的过程中,我们可以把形容词放在名词前面,进行修饰和描绘,有些时候,形容词可以是多个,按一定顺序排列。而在《Noita》的法杖编辑界面,玩家可以在法术前按顺序放入任何素材(比如某些效果、状态,乃至其它法术等),线性声明“子弹”的样子。比如,将[伤害增强]放到[投射物法术]前面:

[伤害增强][投射物法术]

如此,投射物法术的伤害就会得到增强。而如果连续加入多个[伤害增强],效果便会叠加。

[伤害增强][伤害增强][伤害增强][投射物法术]

不仅如此,子弹个数、延迟时间、子弹速度等都可以作为参数随意组合,可想而知,搭配产生的效果是何等自由。

这是个复杂且深刻的系统,玩家可以把某个法术放入魔杖中,用它来“修饰”其他法术。魔杖本身对法术也有影响,它会打乱执行顺序或改变某些概率。而在游戏中,有 200 多个法术可供玩家组合。

比较巧妙的是,这一切都是可视的,很容易上手,所有人都可以从释放一个随机发射的火球开始入门——复杂的法术构成被包裹在简单的操作方法下,玩家很容易被勾起兴趣,自行尝试新的组合。这很优雅,也很简洁。

可以发现,虽然这个系统并非用函数式语言写成,但它的体验和函数式编程的体验很像(如 Lisp)。具体如下:

  1. 编辑法术像是在编写程序语句,释放法术,就是让语句开始执行。即代码就是数据。
  2. “形容词”与“子弹”作为语言的元素,并列出现。即函数也作为参数存在。
  3. 图像会叠加。子弹形状的叠加,很直观,像是在用某种图形语言编程。

再来看另一个例子,《SquishCraft》。

这是一个以推箱子为主要玩法的游戏,每个箱子都带有一个特别机制,箱子既是游戏场景元素,也是玩法元素。在推两个并排堆叠的箱子时,不仅箱子可以被挤压为一体,箱子所包含的玩法元素也会被挤压出新规则,非常神奇。

乍一看,本作与《Noita》似乎没有太多关联,但如果用函数式编程的思路来审视,会发现其实可以用对应的方法解读《SquishCraft》:

  1. 推箱子像是在空间中写程序语句。挤压箱子,就像让语句开始执行。
  2. 在一个空间里,玩法规则(其载体是箱子)和箱子实体同时存在。即函数也是参数。
  3. 图形的叠加。还有光线和折射。挤压后,规则将会如何变化,很直观,像是在用某种图形语言编程。

以上两个游戏示例反映出,具备“函数式编程感“的游戏,更擅长用相同的形式来处理“规则”和“事物”,从而达到元游戏(Metagame)的效果。但这并不是必须的。

《Opus Magnum》

在讨论《Opus Magnum》之前,让我们先看一看上面这张图。

有一列火车从伦敦开往唐卡斯特, 还有一列火车从唐卡斯特开往谢菲尔德。我们可以从伦敦出发,先乘坐前一列火车, 再乘坐后一列火车,到达谢菲尔德。图中箭头并不代表火车实际行驶的路线,而是指一个抽象事实:存在一条从伦敦到谢菲尔德的路线。


符号表达的双义性

仔细思考便可发现,上图中的符号“g。f”不仅可以表示事物间的一种逻辑关系,同时,也可以是事件发生的顺序。在函数式语言 Haskell 中,“g。f”也是这样,它不仅可以表达、论证前后事物间的关系,也可以展现到达某种状态的过程和路线。

同样,在《Noita》的法术系统中,如果将每个技能写作一个字母,那么它们构成的语句“g。f。h。u……”不仅定义了子弹的结构关系,同时也定义了这些子弹发射出去的顺序。


怎样理解双义性

如果把函数看作集合与集合之间的映射,要求相同的输入总能得到相同的输出,双义性会自然而然地产生。这样一来,语句“g。f。h。u……”就不仅定义了原先集合与新集合的内在关系,也表示了“将函数作用于参数”这种“操作”。

《Opus Magnum》

《Opus Magnum》是一款开放式解谜游戏。玩家扮演一位炼金术师,使用各种炼金工程工具,制作关键的药物、宝石和武器。以下图为例,通过对各类机械臂的摆放和设置,目标是制造出一个六边形结构的成品。

游戏中的谜题没有唯一解,可以通过无数种设计方法过关,因此游戏会考察玩家的三项数据:产生六个产物需要的周期、机器所占用的面积,以及制造机器的组件费用之和。当然,这几项数值越小越好。

工程的基础单位有机械臂、轨道、键合符文、消除键合符文、钙化符文,它们组成的可能性非常庞大,使得“更好”的解法总是存在。最好的一个例证是,玩家在“优化”解法时,针对微小的场景变化,可能会诞生多样的“解题”直觉,这些新方案往往需要完全推翻前有方案,重新规划。

《Opus Magnum》作者的脑洞手稿

每个谜题都有一个清晰、简单的目标(产品),玩家则需要放置基础单位,并“制定”基础单位运转的操作顺序(规则)。

制造出成品并不难,但要捋清“为何这几个基础单位能够组合出庞大的设计空间”,往往让人望而却步。不过,尝试将这些基础单位的功能拆解成集合和映射,再结合组合顺序,也许可以掌握一丝线索:


机械伸缩臂

机械伸缩臂可以(不限于)做以下变换,并支持不同变换按顺序组合:

以上变换可能产生的效果如下:

1.改变机械臂尾端所在的位置。

伸展(扩展指令)后,机械臂尾端所在位置会改变

旋转圆心(旋转指令)后,机械臂尾端所在位置会改变

2.改变符文结构的旋转度数。

旋转尾端(枢轴指令)后,符文结构会旋转。在这之后,如果机械臂尾端位置改变,尾端正在抓取的符文结构会以旋转后的状态进行平移。

3.改变机械臂单位以及相关符文在版图(Board)中所占的区域。

执行指令后,机械臂单位本身以及相关符文的占地位置都会改变。


滑动轨道

接下来再看看滑动轨道的作用:对机械臂的轴心位置进行平移。如果这是一个函数a,作用在 “g。f。h。u……” 中的最前面,变成“a。g。f。h。u……” ,那么,其最终效果会完全不同。


键合单位

最后想挑出来谈谈的单位是键合函数。键合函数会组合符文,形成复合的符文结构。结构中的每个符文都可以作为机械臂尾端旋转的轴心。根据任意轴心旋转,效果明显不同。


将上述变换组合起来

介绍过几个重要单位的基础功能后,总结一下它们进行组合的可能性:

  1. 机械臂的[伸展][旋转圆心]、和滑动轨道的[平移函数]可以相互组合。使得当前的[机械臂尾端的位置]可以在任何地方。
  2. [旋转尾端]作用在[机械臂尾端的位置]上,就会旋转当前的原子,允许产生围绕不同轴心旋转后的不同效果(改变符文的位置及其旋转度数)。
  3. [符文]在被机械臂的[旋转尾端]函数组合前,也可以与[键合函数]进行组合,影响最终产生的结果。

《Opus Magnum》在这些变换组合之上,又有许多叠加的设计:

  1. 支持以上变换组合多条并行。里面的每个步骤都可以影响其它并行的组合效果。
  2. 玩家可以通过拿取和放下,来自行选择符文进行组合的时机。也就是说,这个组合链条可以完全是:“g。等待。等待。等待。u。等待。a。……
  3. 听上去,变换组合所能达到的结果是无限的?几乎是的,但每次组合时,还要被各种元素在游戏版图中所占的区域限制。比如,由于机械臂碰撞等原因,有些指令是不允许的。

在这里,也可以看到两义性的影子。玩家可以随意组合各种变换,形成下图中的序列。这个指令组合也有两种理解方式。

当指令作为符号依次组合起来,很像是上文中讨论的具有两义性的“g。f。h。u

第一种是关系之间的组合(上文就是用这种关系对所有可能性进行了全局分析)。

假设 g 代表一次轴心旋转,f 代表一次拉伸变换,那么,“g。f”这样的组合,可以表示为对任意一个机械伸缩臂单位,进行一次轴心旋转后,再进行一次拉伸的变换。允许这样的组合关系存在,就表示“机械伸缩臂”的尾端可以到达以下红色区域中的任意一个位置,也就是全部可能性的总和。

第二种是函数作用于参数的具体操作(即玩家根据具体问题得出当下情景的解决方案)。

同时,上面所举例的符号组合“g。f”,也可被看作一个即将被应用到某具体机械臂上的操作。该操作有一个即时结果,这个结果是对关系组合的“实现”——语句执行完后,修改了对象(比如机械臂)的位置和后续函数。

《Baba Is You》

《Baba Is You》是一个允许玩家用推箱子的方式定义规则(事物逻辑关系)的游戏。玩家可以通过推箱子来构建“g。f”这样的语句。

在《Baba Is You》中,“box is push”给人的感觉很像是“g。f”,它是一个逻辑证明,表示 box 和 push 之间有一条通路。因此,“g。f”这个道路在游戏中的形态,还可以用逻辑词语(NOT、AND)来组合。

《Baba Is You》中的“g。f”是逻辑关系的定义,不过,它的语句效果,则是对游戏规则进行整体更新,可以说,“g。f”的链条,是运用在游戏规则上的映射函数,将旧的规则集合更新为新的规则集合。

《Baba Is You》中的初始规则(集合) 

如果可以用语句定义各种突变的内在关联,那么任何游戏都可以看作一个从[上一秒的世界][下一秒的世界]之间的映射函数

《异星工厂》

图片:Martin Kuppe

数学的分支分布在大陆的各个地方。在这其中有一个高高悬浮的月亮,它提供了一望无际的地形,使我们能够看到各个领域之间的关系,这个强大的月亮就是范畴论(Category Theory)[2]

范畴论是卓越的思想工具,具有强大的概括能力,函数式编程思想也可以说是受范畴论影响的一个数学分支。利用范畴论,游戏和编程之间甚至可以相互翻译。

范畴论教授巴尔托什·米莱夫斯基(Bartosz Milewski)在游玩《异星工厂》(Factorio)时,称自己强烈地想到了“函数式编程” [3] 。比如,游戏中装配了配方(recipe)的组合器让他想到了函数,函数具有 InputOutput 两个口子,那么将此对应到游戏中,则是铜板进入组合器,变成了两个铜圈。

组合器

函数也可以作为其他函数的参数。比如,一个组合器也可以通过和其他零件装配,输出一个更高级的组合器。

配方

游戏中,物品(参数)是怎样被传送到组合器(函数)中的呢?玩家可以手动将物资运到对应组合器中,但这太不符合游戏的科技水平了。必须发明一种自动运送的方法。一种解决方法是,增加一个可以装载物品的容器类型:盒子、车厢、传送带等。而在函数式编程中,对应的就是 functors(函子)。

编程时,List 也是一种典型的 functors,它运送各种不同类型的数据,使它们都变成 List 中的一员。

[原料->组合器->成品]加入“传送带”这个上下文,变成[传送带上的原料->组合器->传送带上的成品]

假设,所有 recipe 函数是一个方法的集合。在函数式编程中,一个 fmap 变换,可以将集合中的所有方法都进行一次提升,使得它们的输入值和返回值都被传送带这个容器所包裹,一次性为所有组合器的可能实现了传送带的上下文。

下文中的 F 就是这样一个 famp 变换,在之前集合内的对象 abc,以及 abc 之间的函数关系,都被 F 包裹了一层运算在外面。一般我们认为,一个函数可以改变对象,但其实它也可以改变对象之间的映射关系。这就像是把一个规则整体转换到另外一个环境(上下文)中。

《Baba Is You》也可以这样去理解:将移动箱子的行为当作一个从 water is sink 的状态上下文到 rock is sink 的上下文的 F 变换,它让场景物件和物件之间的关系(规则)都产生了变化。

巴尔托什·米莱夫斯基认为,虽然《异星工厂》不是为了编程语言而设计的,不能指望它实现编程的各个方面。但思考如何将一些更高级的编程思想翻译成异星工厂内的语言是很有趣的。于是,他在文中尝试讨论了一个问题:Curring 和 Monad 如果出现在《异星工厂》的语境中,会有怎样的表现?类似过程像是在不同语言环境下进行翻译游戏,翻译的对象是思想。

理解后的启发

编程和游戏都需要“如何进行分类”的思想,并用一种“形式的(规则的)”或“系统的”方式来实践这种思想,同时允许使用者利用这个规则来达到目的。

范畴论思想对于编程和游戏的共同启示

在笔者个人的理解中,范畴论带来的启示有二。

  1. 恰到好处的学习过程。

程序结构良好的标志是我们的大脑能有效地处理它们。优雅的代码创建的块大小恰到好处,并且数量恰到好处,使得我们的大脑消化系统正好能够吸收它们。函数式编程在元素组合、连通、关系上所具有的优雅性,是游戏设计中难度渐进的参考对象。遇到复杂的组合、关系时,我们可以思考能否让它们形成一种结构性,以便潜移默化地完成操作维度的更迭和进阶。

  1. 打磨机制,优化组合,自造体验。

如果想打破玩家和设计者的界限,让玩家尝试自行组合机制,就问题提出个人解决方案,那么,游戏也和编程一样,需要具有底层的合理机制,以及动态方面优雅的组合。

西北大学的 Robin、Marc、Robert 在 2004 年 提出 MDA 游戏架构,即机制(Mechanics),动态(Dynamics),体验(Aesthetics)[4] 。三者是这样定义的:

  • 机制(Mechanics)描述了在数据表示和算法层面,游戏的特定组成部分。
  • 动态(Dynamics)描述了机制依据玩家输入、其他机制彼此的输出,随时间推移的运行行为(run-time behavior)。
  • 美感(Aesthetics,玩家体验、美感体验) 描述了玩家与游戏系统交互时,被唤起的预想的情感反应(desirable emotional responses)。

一般认为,MDA 支持一种形式化、迭代的设计和调整方法。它允许我们对特定的设计目标进行明确推理,并预测做出的改变将如何影响框架的每个方面,以及由此产生的设计/实施过程。但是,如果 M、D、A 之间具有非常强力的内部结构关系,玩家便可以与设计师站在相同的视角来理解这个世界。通过将游戏系统的动态行为形式化,玩家也可以在 MDA 的三个抽象层次间移动。将游戏理解为动态系统,有助于我们发展出迭代设计和改进技术。

总之,如果机制和组合设置得贴切、恰当,编程和游戏的体验是相似的:

  1. 优雅。像是使用乐高进行拼接,具有可视感和形式一致性。
  2. 强健。能安全地应付最极端的操作或变化,以不变应万变。
  3. 丰富的组合,这种组合甚至可以是不同抽象层次之间的。通过组合,可以为特定情景打造不同的解决方案,问题的解题空间也许可以分解为一系列最简单的构建单元,以此为基础,组合成更复杂的东西(或解题方法)。

范畴论思想在游戏和编程领域的差异

除去共同点以外,范畴论思想在游戏和编程领域的使用上,也有不可忽视的差异:

底层 vs 表层

编程世界中,对象和对象的关系需要考虑逻辑与自洽,以便实现更灵活和高阶的组合。
游戏世界中,玩家所感觉到的关系是一种印象。这使得游戏设计者的根本任务是变魔术,引导注意力,增加反馈,制造让玩家感受的印象。比如,当某个东西与你之间具有“被吃”的关系,我们不需要知道这个东西是什么,内部有什么,大概可以推测出它是“食物”。

工具

构造这种关系的工具也不一样。编程是通过符号(代码)。游戏是图像的、互动的。利用图像来暗示关系,比用基于逻辑的线性文本建构关系要容易得多。每个像素都非常重要,它们都对关系的错觉做出了贡献。

主题限制

其主题限制也不一样,编程会受到硬件条件和功能目的影响。而游戏似乎完全从中解放了出来,变成与“人”有关。

“函数式编程感”的背后

综上所述,具有“函数式编程感”的游戏可以说包含了以下特质:直观的界面、优雅的组合,规则层面的映射、范畴论思想的体现,庞大的解谜空间……这些设计背后的思想与函数式编程背后的思想遥相呼应。随着函数式编程思想的普及和发展,这些特质会被广泛、反复地应用,延伸和发展,最终司空见惯。那么,函数式编程感的背后,到底隐藏了什么不可忽视、无可替代的东西呢?

符号的组合可以同时表达“关系/逻辑”以及“操作的顺序”。一个是全局的原理,一个是局部的规则,这种双义性可以给我们一些启发。

例如,大部分游戏的制作,都有一个update()函数,针对里面的每个时间步,制作者都可以通过添加一个小增量来改变对象的位置(通过将其速度乘以时间增量来计算)。这种方法关注到底“怎样去做”:我们通过遵循一系列小步骤来得出最终解决方案,每个步骤都取决于前一个步骤的结果。

相对于上述的局部更新,也存在另一种全局性的思考。我们只关心系统的初始状态和最终状态,通过建立一种公式或原理(比如,h = g。f),便可以推测出update()函数中的每一小步增量是如何定义的。

物理世界中的“费马最短时间原理”就是一种全局定义,它指出:光线沿着最短飞行时间的路径传播。我们知道,光在水中的传播速度要比在空气中慢,那么,光从空气射入水中,到达水下某点,它总能找到那条用时最少的路径,仿佛知道未来。这个定理便是一种真实物理世界层面上的表达式,它可以编译出任何光线的update()函数。似乎,在真实物理世界中,“时间”是原理的检验工具。[5]

下图中埃舍尔的画作是另一个更“完美”的例子。这幅画使观者感受到了美妙、复杂、优雅和秩序,甚至,它似乎蕴含着一个特殊的“原理”,这个原理描述了该画作是以怎样的方式逐步生成的。

用函数式程序“编写”而成的埃舍尔画作 [6] :它首先是一幅令人驻足的画,然后是一个谜题,同时,也是一个谜题的答案。

如此看来,这幅画也具有两义性:观者不仅可以将它看作一幅画,也可以由此想象并分解得到画作的构造过程。怎样的构造顺序,才能完成这幅图?怎样去设计函数功能的组合(要做什么),才能精准地描述这幅图的蕴意?

在精准描述的同时,作画的规则也被写出来了,画作本身也完成了。这便是原理的发现和完成。

从这种意义上类比,具有“函数式编程感”的游戏,也拓展了全局的原理(规则)和当下的渐进(行为)之间的辩证可能性。

我们可以想象——游戏背后存在一个自成一体的符号体系(可以是任何东西,如图像、思想、文本……),玩家用这个体系中的符号来搭建世界,而在搭建过程中,必须遵循体系中各个符号的内在限定,整个行为就像发现世界背后的物理规则一样。

作为这种辛勤劳作的奖励,一张《异星工厂》的壮观截图、一个《Opus Magnum》的优雅解法、一个《Noita》中的炮弹串组合……它们既像是一张可以欣赏的图,又像是一个解答方案,有时候还像是一个问题——呼唤着玩家动用游玩时间去领会、创造其背后的原理。



参考资料:

[1] 函数式编程初探,阮一峰
http://www.ruanyifeng.com/blog/2012/04/functional_programming.html
[2] 函数式编程基础--范畴论和 Functor
https://zmxiaodao.com/post/tech/fp/functor/
[3] Functorio-Bartosz Milewski
https://bartoszmilewski.com/2021/02/16/functorio/
[4] MDA: A Formal Approach to Game Design and Game Research
https://users.cs.northwestern.edu/~hunicke/MDA.pdf

[5] Bartosz Milewski's Programming Cafe
https://bartoszmilewski.com/
[6] Einar Høst - Escher in Elm: Picture combinators and recursive fish
https://www.youtube.com/watch?v=eFRYSHDjHMg


封面:《异星工厂》官方博客
*本文内容系作者独立观点,不代表 indienova 立场。未经授权允许,请勿转载。

您可能还会对这些文章感兴趣

参与此文章的讨论

  1. notlsd 2023-06-11

    好文章!

  2. uplighter 2023-06-11

    好有趣!完全颠覆了我对解谜游戏设计的印象。这种函数式编程的思想真的让我眼前一亮,感觉很多东西现在好理解多了。
    前面的技能组合部分可操作性很高,很容易放到各种类型的游戏里面,丰富系统、提升客制化程度。
    后面涉及到解谜的机制设计,大大增加了游戏的抽象色彩(我理解,如果没有显式解,那也大大增加了游戏设计的难度)。
    如果能够找几个简单的机制,组合一下可以出来一大堆变化,那就是很有趣的游戏了。比如早年玩到一款flash游戏叫奇妙反射镜就很简单。
    现在也有一些编程类的游戏,但是它们是命令式编程的思想,比如深圳IO。

  3. Nova-toUyCi 2023-07-01

    好文好文!👍🏻

您需要登录或者注册后才能发表评论

登录/注册