[中文正文内容]

在 Haskell 中制作多人动作游戏

代码

post.md 28.67 KiB 这篇文章是为 Haskell 社区编写的,但也可能对其他游戏开发者和技术受众感兴趣。

Pixelpusher

大家好,我想向大家展示我几乎永远(5 年)都在开发的 Haskell 项目。 这是一个视频游戏!

这是一个简单的多人动作游戏,名为 Pixelpusher,旨在直观且轻松。 您组队,并用无人机互相战斗,主要是试图将它们撞到东西上。 这个 预告片 将向您展示我的意思。

它是 开源的,我也在 Steam 上免费提供。

我想这里的大多数人都会对技术方面的东西感兴趣,所以我将首先讨论这些。 之后,我将进入一些游戏开发方面的内容。

代码

如果您真的想查看代码,您应该从这个概述开始。 请注意:我只重构了代码以修复这篇文章的整体组织。 清理代码的工作量太大了...

因此,代码是用 Haskell 编写的,但是...它没有使用任何很酷的函数式技术,例如函数式响应式编程之类的。 它只是 StateTST 中的普通、非结构化、命令式代码。 我也使用可变数组。 抱歉让您失望了。

这个项目最初是一个编程练习,旨在学习更多关于编写“实用” Haskell 的知识,所以我只是假设我不知道自己在做什么,并允许自己以任何有效的方式实现事物。 我通常不会考虑构建代码,直到更清楚该如何做。

游戏逻辑

游戏逻辑最初是,并且仍然是,只是一堆独立的游戏“系统”,一个接一个地运行。 类似这样的东西

-- 仅用于说明,并非来自游戏的源代码
dynamicsEvents <- runDynamicsSystem mutableDynamicsState inputEvents
combatEvents <- runCombatSystem mutableCombatState inputEvents dynamicsEvents
-- 等等

其中每个系统都将一些事件(不可变消息)作为输入,更改一些内部状态,并输出新事件。

我认为我还没有尝试构建游戏逻辑,因为我实际上有点喜欢这种对游戏系统的显式“调度”。 事件的创建和处理顺序定义明确,因此即使代码是非结构化的,至少您知道并且可以控制事情何时发生(我认为例如 Godot 中的“信号”就没有这种感觉,它们使用回调函数,这些回调函数会在……某个时候被调用??)。

但是! 游戏逻辑的代码在一个方面_很_酷......它是一个纯函数!!

天啊,Haskell 对纯函数有如此好的支持,我什至不必在纯度和就地改变内存之间做出选择,因为 Haskell 具有 ST,并且编译器会为我跟踪所有突变!

将游戏逻辑作为一个纯函数真是太好了。 纯度对于游戏的网络代码至关重要,网络代码需要游戏逻辑成为一个确定性的函数,该函数仅取决于玩家的输入和当前的游戏状态。 这允许服务器仅通过将玩家输入广播到客户端来同步客户端的游戏状态(这称为确定性锁步)。 纯度也使得为游戏实现回滚网络代码成为可能,因为没有副作用会妨碍重置游戏状态并使用不同的输入集重播它。 并且制作回放非常容易:您只需转储初始游戏状态和每帧的所有玩家输入即可。 然后,通过这些回放,您可以重现错误并从快照运行修改后的游戏版本,以查看您的错误修复是否确实已修复。

但是这里的每个人都已经知道纯函数有多么棒了。

游戏状态

独立于游戏逻辑,还有游戏状态如何表示和组织的问题。 我最终通过将游戏状态划分为游戏“系统”来组织它。

并不是说我提倡以这种方式组织游戏状态......这只是我为这个游戏最终所做的事情。 好吧,至少我可以说它对一个“真正的”游戏“有效”; 但话说回来,这个游戏真的很简单,所以也许它不会“扩展”…… 事实就是这样。 总之 --

我将通过示例来解释我所说的“系统”是什么意思:

游戏具有一个“动力学”系统,该系统应处理与游戏实体的空间存在有关的所有内容。 它存储具有“动力学”组件的所有游戏实体的职位、速度、质量和半径(一切都是一个圆圈)。 每帧,它都会更新每个实体的位置并检测碰撞(以及其他事项)。

游戏中的另一个系统是“战斗”系统,该系统处理与实体如何受到伤害有关的所有内容。 它存储具有“战斗”组件的所有游戏实体的当前生命值、最大生命值和无敌状态。 每帧,它会应用生命值再生,应用来自任何碰撞的伤害,并且如果任何东西耗尽生命值并死亡,则会发出事件(以及其他事项)。

从本质上讲,我想有一个地方可以放置与游戏的某个方面相关的所有代码和数据,例如实体的空间存在,或者实体如何受到伤害。 通过将所有内容封装在一个地方,我希望更容易控制并保证该方面的行为。

例如,为了准确地发出一次关于实体死亡的事件,如果我们安排应用所有治疗效果(严格增加生命值的效果)在应用任何伤害效果(严格减少生命值的效果)之前,那么只要在应用伤害将实体的生命值从正值降低到非正值时,发出一个死亡事件就足够了。 或者,让所有伤害都经过同一个函数会很好,这样我就不会忘记发出适当的与伤害相关的事件或考虑诸如实体当前是否处于无敌状态之类的因素。 你可能明白我的意思。

好的,如果游戏状态将被划分为单独的数据存储,那么我们将需要一种方法来关联_跨越_数据存储的数据。 为此,游戏使用 EntityID,如实体组件系统 (ECS) 架构中那样,也称为关系数据库中的主键。 每个游戏实体都分配有一个唯一的 EntityID,并且,将数据存储想像成数据库表,每个数据存储都使用 EntityID 作为其主键。 然后,一个游戏实体,隐式地,是在游戏的数据存储中与其 EntityID 相关的所有数据。

添加约束

所以,我实现了 EntityID 和数据存储的东西,它“有效”并且很好......但是过了一段时间,我感觉将数据拆分成这些单独的存储让我更难保证数据。 就像,我正在为“无人机”实体编写一些游戏逻辑,而且我_在我的脑海里知道_无人机实体总是应该具有动力学组件,但是代码和数据存储并不知道这一点! 在代码中,当我查询动力学存储以获取无人机实体的动力学组件时,它没有返回 DynamicsComponent,而是返回了 Maybe DynamicsComponent。 毕竟,我有可能编写了删除无人机动力学组件的代码并让它......不再出现在游戏世界中了? 什么? 我为什么要那样做!? 这没有任何意义!

不,我不希望能够同时 (1) 具有无人机的 EntityID 且 (2) 在动力学存储中没有它的动力学组件。 因此,我经历了以下麻烦

天啊,当我写出来的时候感觉真是太痛苦了……也许真的是这样……

这是代码的样子(点击展开)

我向 EntityID 添加了一个幻影类型参数,以标识它代表哪种实体。

newtype EntityID (t :: EntityType) = EntityID {unEntityID :: Word16}
type data EntityType
 = Overseer
 | Drone
 | SoftObstacle
 -- ...

我还创建了另一种 EntityID 类型 SEntityID,具有相同的表示形式,但适用于属于实体类型的某个子集的_某些_实体类型的 EntityID

newtype SEntityID (s :: EntityTypeSet) = SEntityID {unSEntityID :: Word16}
type data EntityTypeSet
 = AnyEntity
 | DynamicsEntity
 | CombatEntity
 -- ...

EntityID 的高位表示数据存储后备数组的整数索引,而低位表示实体类型,该实体类型可以在运行时“重新发现”:

refineEntityID :: (IsEntityTypeSet set) => SEntityID set -> ViewEntityID set
refineEntityID = _refineEntityID
data ViewEntityID :: EntityTypeSet -> Type where
 OverseerID :: (IsSetMember Overseer subsetType) => EntityID Overseer -> ViewEntityID subsetType
 DroneID :: (IsSetMember Drone subsetType) => EntityID Drone -> ViewEntityID subsetType
 SoftObstacleID :: (IsSetMember SoftObstacle subsetType) => EntityID SoftObstacle -> ViewEntityID subsetType
 -- ... 每个实体类型的构造函数
-- | 用于定义实体类型集的类。
class IsEntityTypeSet (set :: EntityTypeSet) where
 -- | 内部。表示集合成员的实体类型列表。
 type SetMembers (set :: EntityTypeSet) :: [EntityType]
 -- | 内部。优化 'SEntityID' 以发现其实体类型。
 _refineEntityID :: SEntityID set -> ViewEntityID set
instance IsEntityTypeSet CombatEntity where
 type SetMembers CombatEntity = '[Overseer, Drone]
 _refineEntityID eid =
  case getSEntityTag eid of
   OverseerTag -> OverseerID (coerce eid)
   DroneTag -> DroneID (coerce eid)
   _ -> error "unexpected tag when matching SEntityID CombatEntity"

并且数据存储具有类型参数,以限制它们将接受的实体类型。

type DynamicsStore' s =
 DenseStore
  s
  (SEntityID DynamicsEntity) -- _可能_具有动力学组件的实体子集
  (SEntityID DynamicsEntity) -- _必须_具有动力学组件的实体子集(上述子集的一个子集,在这种情况下恰好相等)
  DynamicsComponentVec

然后,在创建和销毁实体的这个特殊模块中,我必须手动确保我添加和删除完全正确的组件,以匹配这些实体类型集中指定的组件。 (天啊……)

但结果是,在该特殊模块之外,一切都有效! 我可以从战斗系统中的一个事件中获取一个 SEntityID CombatEntity,并使用它来获取一个 DynamicsComponent 而无需检查 Maybe,因为 (1) 静态已知 CombatEntity 集是 DynamicsEntity 集的一个子集,并且 (2) 保证 DynamicsEntity 集中的所有实体类型都具有动力学组件。

万岁? 我的意思是,编写游戏逻辑感觉很好,因为当我实现新的机制时,东西不会中断......但是,你知道,当我这样做时,我必须更新很多脚手架。 这意味着我不能只是非常快速地编写一些完全不健全的东西,因为_只是让我看看这个想法可能会如何发挥作用_。 好吧,这并不是我的工作方式......但是......你认为如果我的代码允许这样做,我会以不同的方式学会创造吗?

嗯嗯嗯嗯嗯! 我们现在不要考虑这个问题!

无论如何,此数据存储内容占了游戏状态的大部分。 其余的都附加在 STRef 中。

杂项想法

这款游戏并不是 Steam 上唯一的 Haskell 游戏 -- Defect Process 几年前就发布了!

作者编写了 游戏代码的概述,并包括了一些关于他们经历的“杂项想法”,我发现自己同意。 特别是,

奖励:运行时系统统计信息,用于垃圾回收暂停

当我启动这个项目时,我的朋友怀疑是否可以在 Haskell 中顺利运行动作游戏,因为它存在垃圾回收暂停; 也就是说,当垃圾回收器需要运行时,玩家会在游戏过程中遇到破坏性的卡顿。 我也遇到过其他有同样印象的人。

事实证明,对于这款游戏来说,这不是问题。

这是我从运行 8v8 bot 游戏一分钟后获得的运行时系统统计信息。 我使用了 GHC 9.8.2 与线程运行时,非移动垃圾回收器和两个核心(尽管游戏逻辑本身是单线程的)。

运行时系统统计信息(点击展开)

  Alloc  Copied   Live   GC   GC   TOT   TOT Page Flts
  bytes   bytes   bytes  user  elap   user   elap
 4296048   80800  923592 0.000 0.000  0.006  0.005  0  0 (Gen: 1)
# sync 0.000
 4414968  2158544  3273856 0.004 0.004  0.065  0.073  0  2 (Gen: 0)
 4183216  2500320  6556696 0.005 0.005  0.070  0.078  0  0 (Gen: 1)
 4186192  2484312  9393208 0.004 0.004  0.074  0.082  0  0 (Gen: 0)
 4186192  2484648 12726664 0.004 0.004  0.079  0.086  0  0 (Gen: 0)
 4186200  2484960 16025952 0.004 0.004  0.083  0.090  0  0 (Gen: 0)
# sync 0.000
 7359480  1311968  8831552 0.002 0.002  0.103  0.104  0  0 (Gen: 0)
 4379728  462648  8927384 0.001 0.001  0.110  0.112  0  0 (Gen: 0)
 4168384  755632  9667672 0.001 0.001  0.111  0.113  0  0 (Gen: 0)
 4188112  568472 10354048 0.001 0.001  0.114  0.115  0  0 (Gen: 0)
 4199472  746152 11111704 0.001 0.001  0.120  0.120  0  2 (Gen: 0)
 4192712  575800 11753344 0.001 0.001  0.124  0.123  0  1 (Gen: 0)
 4189720  527680 12434920 0.001 0.001  0.128  0.126  0  0 (Gen: 0)
 5884472  180224 13258208 0.000 0.000  0.232  0.231  0  0 (Gen: 0)
 10645208   12792 23245728 0.000 0.000  0.378  0.377  0  0 (Gen: 0)
 10637888   3728 23242712 0.000 0.000  0.528  0.526  0  0 (Gen: 0)
 10637888    712 23242712 0.000 0.000  0.676  0.673  0  0 (Gen: 0)
 10637888    712 23242712 0.000 0.000  0.820  0.817  0  0 (Gen: 0)
 5341336    712 17946160 0.000 0.000  0.926  0.921  0  0 (Gen: 0)
 5333024   1080 17946504 0.000 0.000  1.007  1.002  0  0 (Gen: 0)
 8254872   44368 12915712 0.000 0.000  1.099  1.627  0  4 (Gen: 0)
...
[1372 garbage collections excluded]
...
 4312120  139120 59869184 0.000 0.000  9.373  60.910  0  0 (Gen: 0)
 4302088  171232 59966200 0.000 0.000  9.379  60.961  0  0 (Gen: 0)
 4273744   99600 59901704 0.000 0.000  9.386  61.010  0  0 (Gen: 0)
 4314592  111568 59966472 0.000 0.000  9.391  61.044  0  0 (Gen: 0)
 4306080  167888 60108688 0.000 0.000  9.397  61.094  0  0 (Gen: 0)
 4301496  155176 60098640 0.000 0.000  9.403  61.144  0  0 (Gen: 0)
 4261984  140432 60134568 0.000 0.000  9.409  61.193  0  0 (Gen: 0)
 4308152  123592 60217560 0.000 0.000  9.414  61.228  0  0 (Gen: 0)
 4315384  127936 60294472 0.000 0.000  9.420  61.278  0  0 (Gen: 0)
 4124448  115424 60423624 0.000 0.000  9.440  61.345  0  0 (Gen: 1)
  6,060,556,112 bytes allocated in the heap
   228,569,912 bytes copied during GC
   60,423,624 bytes maximum residency (16 sample(s))
   11,756,816 bytes maximum slop
       72 MiB total memory in use (0 MiB lost due to fragmentation)
                   Tot time (elapsed) Avg pause Max pause
 Gen 0   1386 colls, 1386 par  0.333s  0.334s   0.0002s  0.0040s
 Gen 1    16 colls,  15 par  0.011s  0.010s   0.0007s  0.0047s
 Gen 1    16 syncs,            0.000s   0.0000s  0.0000s
 Gen 1   concurrent,       0.129s  0.143s   0.0089s  0.0262s
 Parallel GC work balance: 1.22% (serial 0%, perfect 100%)
 TASKS: 23 (16 bound, 7 peak workers (7 total), using -N2)
 SPARKS: 0 (0 converted, 0 overflowed, 0 dud, 0 GC'd, 0 fizzled)
 INIT  time  0.001s ( 0.001s elapsed)
 MUT   time  8.965s ( 61.000s elapsed)
 GC   time  0.345s ( 0.344s elapsed)
 CONC GC time  0.129s ( 0.143s elapsed)
 EXIT  time  0.001s ( 0.000s elapsed)
 Total  time  9.440s ( 61.345s elapsed)
 Alloc rate  676,013,041 bytes per MUT second
 Productivity 96.3% of total user, 99.4% of total elapsed

对于那些不熟悉这些统计信息的人,GHC 用户指南有一个 解释它们的章节。 向下滚动到以“如果您使用 -s 标志...”和“-S 标志,以及...”开头的段落。 与 GHC 用户指南相比,我们在末尾的摘要统计信息中有两个额外的“Gen 1”行,因为我们使用的是非移动垃圾回收器。 最后一个“Gen 1”行描述了非移动垃圾回收器的并发阶段所花费的时间,但是这些阶段不会暂停程序执行,因此我们可以忽略本节中的这些阶段。

从底部的摘要统计信息中,我们得到的平均垃圾回收暂停时间小于 1 毫秒,最大暂停时间约为 5 毫秒。 作为参考,由于我们每秒更新和显示游戏状态 120 次,因此我们每个帧的时间预算为 8.3 毫秒。 5 毫秒的暂停将超过我们时间预算的 50%,这是不可接受的。

但是,查看上面各个垃圾回收的统计信息,我们看到所有最长的暂停都发生在游戏启动的最开始,在游戏开始之前。 游戏过程中的暂停时间较短:在 1382 次垃圾回收中,1 次花费了 2 毫秒(经过的时钟时间),6 次花费了 1 毫秒,其余的都花费了 0 毫秒。

对于不使用大量 CPU 的简单游戏,这些暂停时间是可以接受的。 我在玩游戏时没有注意到任何卡顿。

游戏开发

关于该项目的技术方面,我没有太多要说的了。 尽管它们肯定是项目的基础,但是随着项目的进行,它们慢慢地变成了游戏的众多部分之一,不比其他任何部分更重要或更不重要。

让我分享一些我最感兴趣的部分。

声音合成

游戏中的声音是使用用于声音合成的特定领域语言 Faust 制作的。

如果您感到好奇,游戏声音的源代码 在这里

有很多用于声音合成的工具,但是我只是选择了这个,因为它说它是一种函数式语言(我喜欢这些!)。 用它编写代码感觉就像将电路连接在一起,并且您可以想像该语言在幕后为您处理所有可怕的数字信号处理工作。

但是,天啊,声音设计和声音合成太难了。 严重的是,它们让我感到如此虚弱。 我花了整整两天时间才制作出一个简单的声音。 为了达到我所达到的程度,我主要只是摸索并将简单的元素拼凑在一起(它们是我唯一可以理解的元素)。 不过,有一次我使用了一种效果很好的技术!

基本上 -- 不要引用我的任何话,但是 -- 很多声音都是由物体被击打并振动直到停止的声音。 物体具有这些振动模式,某些频率(在这些频率下会发生共振)取决于它们的形状和材料,这些频率构成了它们的声音。 该技术的思想是,您可以通过复制物体的振动模式及其特征来模仿物体的声音。 碰撞的声音就是这样制作的。

游戏中碰撞的实体具有圆环的形状,一个中间有孔的圆盘,因此它就像一个垫圈。 我找到了一篇关于盘式制动器振动模式的工程论文(我认为用于飞机,它们不希望振动?),采用了其中的一个图,并以视觉方式读取了一个盘片的振动模式的频率和幅度。 我找不到每个模式能量衰减的速率,因此我使这些速率与频率成正比(我在某处读到这是一般趋势)。 然后,我将这些参数应用于 Faust 的一个物理建模函数。 它输入一小段白噪声(包含所有频率)以并行激发一组谐振器(每个振动模式一个),这些谐振器拾取其谐振频率处的能量并开始振动,从而产生输出声音。 在统一缩放参数以符合口味后,结果 还不错!

好吧,实际上,我认为我只是对这种声音感到幸运。 我尝试了相同的技术,使用了另一篇分析冥想碗振动的论文,但听起来很糟糕。 呃呃呃呃呃

我们仍然需要为这种声音做另一件事。 我们需要制作它的变体,因为重复听到相同的碰撞声音会很快变得陈旧。 为此,我们可以改变振动模式的幅度,因为这有点像在不同的位置撞击物体会增加能量在各个模式中的不同分布的方式。 在游戏中,我还对声音应用了小的音高变化,以获得更多变化。 空间音频也有帮助。

Bot 逻辑

我花了很多时间编写游戏的 bot。 最初我真的不想这样做(我避免了 3 年),但是我最终意识到我别无选择:实际上没有人会玩独立多人游戏,如果他们需要聚在一起才能尝试。 最初我考虑使用强化学习或机器学习,但是在从 Haskell GameDev Discord 获得一些好建议后,我采取了更动手的和增量的方法,手动编写了一堆启发式方法。

这种方法效果很好! 我只是尝试编写具体的游戏说明,就了解了很多关于游戏的信息。 并且只是有 bot 来进行游戏测试也非常方便。 我觉得我不能要求我的朋友们多次与我一起进行游戏测试......但是 bot 很高兴玩,即使在凌晨 3 点! 我认为我与 bot 一起进行游戏测试的次数是与朋友一起进行游戏测试的次数的 50 倍。 而且随着我在 bot 上的工作,它们成为了越来越好的练习伙伴。 当需要捕获游戏片段以制作预告片时,bot 在电影上的表现比我的朋友们好得多。 预告片中的所有片段中,除了一个,实际上都是我与 bot 对战的片段!

但这并不是说 bot 很聪明或什么的。 它们没有战术或战略或团队合作的概念,它们仍然不知道如何使用某些游戏机制(我不知道该告诉它们做什么),并且它们可能会做更多的事情来帮助玩家提高游戏水平。 不过,bot 的优势在于它们如何移动和躲避以避免敌人,所以这就是我现在要讲的内容。

规避敌人

bot 如何避免敌人? 它们构建了一个敌人威胁的“势场”:一个将标量威胁级别分配给空间中每个点的函数。 Bot 计算出它所能做的所有可能动作的最终位置,评估每个位置的势,然后选择与具有最低势的位置相关的动作。

如何构建这个势场? 势场是由各个实体产生的势场的总和(加上一堆临时细节)。 每个敌方实体都会产生两个势场:一个更详细的、短程的势场,用于躲避迫在眉睫的碰撞,以及一个更模糊的、远程的势场,用于移动到更安全的位置。

短程场由连接实体当前位置到实体如果保持其当前速度在短时间内将到达的位置的线段构成。 势在该线段上达到其最大值,并且其值随着与线段的距离的增加而减小,直到最小值为 0。 因为此势的梯度垂直于敌人的速度(好吧,不是_到处_,但是你知道我的意思),它方便地奖励了机器人避开接近的敌人,这通常是在运动具有显着惯性的游戏中正确的举动。

远程场是一个大的钟形斑点(有点像高斯分布),以实体在滑行并让其动量将其带到静止的位置为中心。 我希望这个斑点代表对敌人最终位置的愚蠢的、没有任何假设的猜测(但谁真的知道为什么它有效)。 盟军实体也会产生这个场,但取反,代表远离威胁的安全。 当我添加这个远程场时,与 bot 对战变得更加有趣! 以前,它们太容易被困住和包围了 -- 你基本上可以用你的动作来引导它们的动作,使它们直接跑向你的盟友。 现在,它们的移动比我好 -- 它们感觉很滑,很难抓住!

这基本上就是 bot 的移动方式。

基准测试和调整

另一方面,在开发 bot 时,我发现运行以 CPU 允许的最快速度运行的无头 bot 游戏而不是以人类速度运行真的非常有用。 你给一个团队旧的 bot 逻辑,另一个团队新的 bot 逻辑,经过数百场游戏后,你可以看到你的更改是否真的帮助一个团队赢得更多(并不是说擅长游戏是我们在乎 bot 的唯一事情)。 拥有这种更“客观”的衡量方法是消除我所有天才游戏 AI 想法的好方法。 否则,我只能通过观察我个人与旧 bot 和新 bot 对战的经验之间的差异来判断更改,这有时可能存在偏差。

不过,我不能总是依赖这些 bot 游戏胜率。 例如,添加帮助 bot 避免被包围的远程场实际上会使它们与敌人对抗时表现更差:在运行 1000 场 bot 游戏中,使用该场的 bot 团队仅赢得了针对没有该场的 bot 团队的 22% 的游戏。 也许这可以用 bot 只有幼稚的攻击模式并且无法与盟友执行诸如包围之类的战术来解释,因此远程场没有提供任何好处,但仍然会干扰躲避即将发生的碰撞。 好吧,无论原因是什么,对于这种情况,我决定采用我的个人经验,这清楚地告诉我带有远程场的 bot 更有趣。

致谢

这就是我要说的全部。 现在我要列出一堆帮助我制作游戏的东西。