开源应用架构(第 1 卷)
千秒差距

艾伦·劳迪奇纳 和 亚伦·马夫里纳克

一个巨大的星际帝国横跨一百个世界,在太空中延伸了千秒差距。与银河系其他一些区域不同,这里很少有战士;这是一个知识分子的人民,有着丰富的文化和学术传统。他们宏伟的星球,围绕着伟大的科学和技术大学,一轮又一轮地建造起来,是这个和平与繁荣时代的灯塔。来自象限及其以外广阔区域的星舰不断抵达,载着来自四面八方最杰出的研究人员。他们前来为有感知力的生物所进行的最雄心勃勃的项目贡献自己的技能:开发一个分布式计算机网络,连接整个银河系,包括其各种语言、文化和法律体系。

千秒差距不仅仅是一款视频游戏:它是一个框架,拥有构建多人回合制太空帝国战略游戏的完整工具包。它通用的游戏协议允许对客户端、服务器和人工智能软件进行不同的实现,以及各种可能的游戏。虽然它的规模使得规划和执行具有挑战性,迫使贡献者在过度的垂直开发和过度的水平开发之间保持微妙的平衡,但这也使其在讨论开源应用架构时成为一个相当有趣的样本。

记者对千秒差距游戏所属类型的标签是“4X”——它是“explore(探索)、expand(扩张)、exploit(开发)和exterminate(消灭)”的缩写,是控制帝国的玩家的操作方式1。在典型的 4X 类游戏中,玩家将进行侦察以揭示地图(探索),创建新的定居点或扩展现有定居点的影响力(扩张),在他们控制的区域收集和使用资源(开发),以及攻击和消灭对手玩家(消灭)。对经济和技术发展、微观管理和各种统治路线的强调,使游戏的深度和复杂性在更大的策略类型中无与伦比。

从玩家的角度来看,千秒差距的游戏中包含三个主要组件。首先是客户端:这是玩家通过它与宇宙交互的应用程序。它通过网络连接到服务器——使用至关重要的协议进行通信——其他玩家(或在某些情况下,人工智能)的客户端也连接到该服务器。服务器存储整个游戏状态,在每个回合开始时更新客户端。然后,玩家可以执行各种操作并将它们传回服务器,服务器计算下一回合的最终状态。玩家可以执行的操作的性质由规则集决定:它本质上定义了正在玩的游戏,在服务器端实现和执行,并由任何支持的客户端为玩家实现。

由于可能的游戏种类繁多,以及支持这种多样性所需的架构复杂性,千秒差距对于游戏玩家和开发人员来说都是一个激动人心的项目。我们希望即使是对游戏框架解剖不太感兴趣的资深程序员也可能从客户端-服务器通信、动态配置、元数据处理和分层实现的底层机制中找到价值,所有这些机制多年来在典型的开源风格中都朝着良好的设计自然发展。

千秒差距的核心主要是一套游戏协议和其他相关功能的标准规范。本章主要从这个抽象的观点来讨论该框架,但在许多情况下,参考实际的实现会更有启发性。为此,作者选择了每个主要组件的“旗舰”实现来进行具体的讨论。

案例模型客户端是tpclient-pywx,一个相对成熟的基于 wxPython 的客户端,目前支持最广泛的功能和最新的游戏协议版本。它由libtpclient-py支持,这是一个提供缓存和其他功能的 Python 客户端辅助库,以及libtpproto-py,一个实现最新版本千秒差距协议的 Python 库。对于服务器,tpserver-cpp,支持最新功能和协议版本的成熟 C++ 实现,是该样本。该服务器拥有众多规则集,其中导弹和鱼雷战争里程碑规则集是典范,因为它最大限度地利用了功能,并且是一个“传统”的 4X 太空游戏。

21.1. 星际帝国的解剖

为了正确介绍构成千秒差距宇宙的事物,首先对游戏做一个简要概述是有意义的。为此,我们将考察导弹和鱼雷战争规则集,该规则集是该项目的第二个里程碑规则集,它利用了当前千秒差距协议主线版本中的大多数主要功能。这里会使用一些尚未熟悉的术语;本节的其余部分将阐明这些术语,以便所有部分都落到实处。

导弹和鱼雷战争是一个高级规则集,因为它实现了千秒差距框架中可用的所有方法。在撰写本文时,它是唯一实现该规则集的规则集,并且正在迅速扩展以成为更完整、更有趣的游戏。

建立与千秒差距服务器的连接后,客户端会探测服务器以获取游戏实体列表,并继续下载整个目录。该目录包括构成游戏状态的所有对象、棋盘、消息、类别、设计、组件、属性、玩家和资源,所有这些都将在本节中详细介绍。虽然这对于客户端来说似乎在游戏开始时——以及每个回合结束时——需要消化很多信息,但这些信息对于游戏来说绝对至关重要。一旦下载了这些信息,这通常需要几秒钟的时间,客户端现在就拥有了将信息绘制到其对游戏宇宙的表示中的所有必要信息。

首次连接到服务器时,会生成一个随机星球并将其分配为新玩家的“母星”,并在那里自动创建两个舰队。每个舰队由两个默认的侦察兵设计组成,包括一个侦察兵船体和一个 Alpha 导弹发射器。由于没有添加爆炸性组件,因此默认舰队尚不具备舰队与舰队或舰队与星球的作战能力;事实上,它是一个活靶子。

此时,对于玩家来说,开始为舰队配备武器非常重要。这是通过使用“建造武器”命令创建武器设计,然后通过“装载武器”命令将成品加载到目标舰队来实现的。建造武器命令将星球的资源——每个星球都有随机分布分配的资源量和比例——转化为成品:一个爆炸性弹头,种植在创建星球的表面。然后,装载武器命令将这种完成的武器转移到等待的舰队上。

一旦星球上容易获得的表面资源用完,就必须通过采矿获得更多资源。资源有两种其他状态:可开采和不可开采。在星球上使用“采矿”命令,可开采资源可以随着时间的推移转化为表面资源,然后可以用于建造。

21.1.1. 对象

在千秒差距宇宙中,每一件物理事物都是一个对象。事实上,宇宙本身也是一个对象。这种设计允许游戏中有无限数量的元素,同时对于只需要几种类型的对象的规则集来说仍然很简单。除了添加新的对象类型之外,每个对象都可以存储一些它自己的特定信息,这些信息可以通过千秒差距协议发送和使用。目前默认情况下提供了五种基本内置对象类型:宇宙、星系、星系系统、星球和舰队。

宇宙是千秒差距游戏中最顶层的对象,并且始终可供所有玩家访问。虽然宇宙对象实际上并没有对游戏施加太多控制,但它确实存储了一条非常重要的信息:当前回合数。在千秒差距的行话中也被称为“年份”,回合数自然会在每个回合完成之后递增。它存储在一个无符号的 32 位整数中,允许游戏运行到第 4,294,967,295 年。虽然从理论上来说并非不可能,但迄今为止,作者还没有看到游戏进行到这个地步。

星系是许多近距离对象的容器——星系系统、星球和舰队——并且没有提供额外的信息。游戏中可能存在大量的星系,每个星系都承载着宇宙的一部分。

与前两个对象一样,星系系统主要是一个用于更低级别对象的容器。但是,星系系统对象是客户端以图形方式表示的第一层对象。这些对象可能包含星球和舰队(至少暂时)。

星球是一个巨大的天体,可以被居住,并提供资源矿山、生产设施、地面武装等等。星球是第一个可以被玩家拥有的对象层级;拥有星球是一项不可轻视的成就,没有拥有任何星球是规则集宣布玩家失败的典型条件。星球对象存储了大量的數據,包括以下内容

上面描述的内置对象为遵循传统 4X 太空游戏公式的许多规则集提供了良好的基础。自然地,为了遵循良好的软件工程原则,可以在规则集中扩展对象类。因此,规则集设计者能够根据规则集的要求创建新的对象类型或在现有对象类型中存储额外的信息,从而在游戏中可用的物理对象的扩展性方面几乎不受限制。

21.1.2. 命令

由每个规则集定义,命令可以附加到舰队和星球对象。虽然核心服务器没有附带任何默认命令类型,但它们是即使是最基本的游戏也必不可少的组成部分。根据规则集的性质,命令可以用来完成几乎任何任务。本着 4X 类型的精神,有一些标准命令在大多数规则集中都有实现:这些是移动、拦截、建造、殖民、采矿和攻击命令。

为了实现 4X 的第一个必要条件 (explore),需要能够在宇宙地图上移动。这通常通过附加到舰队对象的移动命令来实现。本着千秒差距框架灵活和可扩展的精神,移动命令可以根据规则集的性质以不同的方式实现。在Minisec导弹和鱼雷战争中,移动命令通常以 3D 空间中的一个点作为参数。在服务器端,会计算预计到达时间,并将所需的回合数发送回客户端。移动命令在没有实现团队合作的规则集中也充当伪攻击命令。例如,在 Minisec 和导弹和鱼雷战争中,移动到敌方舰队占据的点几乎肯定会随之而来的是一段激烈的战斗。一些支持移动命令的规则集以不同的方式对其进行参数化(即不使用 3D 点)。例如,Risk 规则集只允许将单回合移动到通过“虫洞”直接连接的星球。

通常附加到舰队对象,拦截命令允许一个对象在太空中与另一个对象(通常是敌方舰队)相遇。这个命令类似于移动命令,但是由于两个对象在执行回合期间可能朝不同的方向移动,因此仅使用空间坐标无法直接降落在另一个舰队上,因此需要一种不同的命令类型。拦截命令解决了这个问题,可以用来消灭深空中的敌方舰队或在危急时刻抵挡即将到来的攻击。

建造命令有助于实现 4X 规则中的两个关键要素——扩展和利用。在宇宙中扩展的最明显方法是建造许多舰队并将其移动到很远的地方。建造命令通常附加到星球对象,并且通常与星球包含的资源数量以及资源的利用方式有关。如果玩家有幸拥有一个富含资源的家乡星球,那么该玩家可以通过建造获得游戏初期优势。

与建造命令一样,殖民命令也有助于实现扩展和利用的关键要素。殖民命令几乎总是附加到舰队对象,它允许玩家接管未被占领的星球。这有助于在整个宇宙中扩展对星球的控制。

采矿命令体现了利用的关键要素。这个命令通常附加到星球对象和其他天体,它允许玩家开采表面上没有立即可用的未利用资源。这样做会将这些资源带到地表,使其能够随后用于建造,并最终扩展玩家对宇宙的控制。

攻击命令在某些规则集中得以实现,它允许玩家显式地与敌方舰队或星球发动战斗,从而实现 4X 规则中的最后一个关键要素(消灭)。在基于团队的规则集中,包含一个独特的攻击命令(而不是仅仅使用移动和拦截来隐式地攻击目标)对于避免误伤和协调攻击非常重要。

由于千帕秒框架要求规则集开发者定义自己的命令类型,因此他们可以跳出框框,创建其他地方没有的自定义命令,甚至鼓励他们这样做。将额外数据打包到任何对象中的能力允许开发者使用自定义命令类型做一些非常有趣的事情。

21.1.3. 资源

资源是附加到游戏中的对象的额外数据。资源被广泛使用,特别是被星球对象使用,它们允许轻松扩展规则集。与千帕秒中的许多设计决策一样,可扩展性是包含资源的驱动力。

虽然资源通常由规则集设计师实现,但框架中始终使用一种资源:家园星球资源,用于识别玩家的家园星球。

根据千帕秒最佳实践,资源通常用于表示可以转换为某种类型对象的东西。例如,Minisec 实现了一种船舶零件资源,该资源以随机数量分配给宇宙中的每个星球对象。当其中一个星球被殖民时,就可以使用建造命令将这种船舶零件资源转换为实际的舰队。

导弹与鱼雷战争可能是迄今为止对资源使用最广泛的规则集。它是第一个武器具有动态特性的规则集,这意味着武器可以从星球上添加到船舰上,也可以从船舰上移除并重新添加到星球上。为了解决这个问题,游戏为游戏中创建的每种武器创建了一种资源类型。这允许船舰通过资源识别武器类型,并在宇宙中自由移动它们。导弹与鱼雷战争还使用与每个星球绑定的工厂资源来跟踪工厂(星球的生产能力)。

21.1.4. 设计

在千帕秒中,武器和船舰都可能由各种组件组成。这些组件组合在一起形成了设计的基础——一种可以建造和使用在游戏中的东西的原型。在创建规则集时,设计师必须立即做出一个决定:规则集应该允许动态创建武器和船舰设计,还是仅仅使用预定的设计列表?一方面,使用预先打包的设计的游戏更容易开发和平衡,但另一方面,动态创建设计为游戏添加了全新的复杂性、挑战和乐趣。

用户创建的设计允许游戏变得更加高级。由于用户必须战略性地设计自己的船舰和武器,因此在游戏中增加了差异性,这有助于减轻可能因运气(例如,位置)和其他游戏策略方面而赋予玩家的巨大优势。这些设计受每个组件规则的约束,这些规则在千帕秒组件语言(TPCL,本章后面会介绍)中概述,并且特定于每个规则集。结果是,开发人员无需额外编程功能来实现武器和船舰的设计;为规则集中的每个可用组件配置一些简单的规则就足够了。

如果没有周密的计划和适当的平衡,使用自定义设计的巨大优势可能会成为其败笔。在游戏的后期阶段,可能会花费大量时间设计新的武器和船舰类型来建造。在客户端为设计操作创建良好的用户体验也是一项挑战。由于设计操作可能是某个游戏不可或缺的一部分,而对于另一个游戏则完全无关紧要,因此将设计窗口集成到客户端是一个重大障碍。千帕秒最完整的客户端 tpclient-pywx 目前将该窗口的启动器放置在一个相对偏僻的地方,即菜单栏的子菜单中(该子菜单在游戏中很少使用)。

设计功能旨在让规则集开发者轻松访问,同时允许游戏扩展到几乎无限的复杂程度。许多现有的规则集只允许预先确定的设计。然而,导弹与鱼雷战争允许从各种组件中完整地设计武器和船舰。

21.2. 千帕秒协议

可以说,千帕秒协议是该项目中所有其他内容的基础。它定义了规则集编写者可用的功能、服务器的工作方式以及客户端应该能够处理的内容。最重要的是,就像星际通信标准一样,它允许各种软件组件相互理解。

服务器根据规则集提供的指令管理游戏的实际状态和动态。每回合,玩家的客户端都会收到一些关于游戏状态的信息:对象及其所有权和当前状态、正在进行的命令、资源库存、技术进步、消息以及对该特定玩家可见的所有其他内容。然后,玩家可以根据当前状态执行某些操作,例如发布命令或创建设计,并将这些操作发送回服务器,以将其处理到下一回合的计算中。所有这些通信都以千帕秒协议为框架。这种架构的一个有趣且刻意产生的效果是,AI 客户端(它们是服务器/规则集之外的,并且是游戏中提供电脑玩家的唯一手段)受与人类玩家使用的客户端相同的规则约束,因此无法通过不正当地访问信息或弯曲规则来“作弊”。

协议规范描述了一系列帧,这些帧是分层的,因为每个帧(除了头帧)都具有一个基础帧类型,它在其自身的基础上添加了自己的数据。存在各种抽象帧类型,这些类型永远不会被显式使用,但仅存在于描述具体帧的基础。帧也可以具有指定的 direction,其意图是此类帧只需要由一方(服务器或客户端)支持发送,而由另一方支持接收。

千帕秒协议旨在通过 TCP/IP 单独运行,或者通过其他协议(如 HTTP)进行隧道传输。它还支持 SSL 加密。

21.2.1. 基础

协议提供了一些在客户端和服务器之间通信中无处不在的通用帧。前面提到的Header帧只是通过其两个直接后代Request帧和Response帧为所有其他帧提供基础。前者是启动通信(无论哪个方向)的帧的基础,而后者是这些帧促使的帧的基础。OKFail帧(都是Response帧)提供了交换中布尔逻辑的两个值。Sequence帧(也是Response)指示接收方多个帧将作为其请求的响应而跟随。

千帕秒使用数字 ID 来寻址事物。因此,存在一个帧词汇表,通过这些 ID 推送数据。Get With ID帧是使用此类 ID 获取事物的基本请求;还有一个Get With ID and Slot帧用于位于具有 ID 的父级事物(例如,对象上的命令)中的“插槽”中的事物。当然,通常需要获取 ID 序列,例如,在最初填充客户端的状态时;这通过Get ID Sequence类型的请求和ID Sequence类型的响应来处理。请求多个项目的常见结构是Get ID Sequence请求和ID Sequence响应,随后是一系列Get With ID请求和描述所请求项目的适当响应。

21.2.2. 玩家和游戏

在客户端开始与游戏交互之前,需要解决一些礼节问题。客户端必须首先向服务器发出Connect帧,服务器可能会用OKFail进行响应——因为Connect帧包含客户端的协议版本,因此失败的一个原因可能是版本不匹配。服务器还可以用Redirect帧进行响应,用于移动或服务器池。接下来,客户端必须发出Login帧,该帧识别并可能验证玩家;如果服务器允许,新手玩家可以使用Create Account帧。

由于千帕秒的巨大可变性,客户端需要某种方法来确定服务器支持哪些协议功能;这是通过Get Features请求和Features响应来实现的。服务器可能会响应的一些功能包括

同样,Get Games请求和一系列Game响应会通知客户端有关服务器上活动游戏的性质。单个Game帧包含有关游戏的以下信息

当然,玩家需要知道自己面对的是谁(或者合作的是谁,视情况而定),为此有一组帧。交换遵循常见的项目序列模式,包括一个Get Player IDs请求、一个List of Player IDs响应,以及一系列Get Player Data请求和Player Data响应。Player Data帧包含玩家的姓名和种族。

游戏中的回合也通过协议控制。当玩家完成操作后,他或她可以通过Finished Turn请求发出准备进入下一回合的信号;当所有玩家都发出此信号后,将计算下一回合。回合也有服务器强制执行的时间限制,以防止速度慢或无响应的玩家拖延游戏;客户端通常会发出Get Time Remaining请求,并使用设置为服务器Time Remaining响应中的值的本地计时器跟踪回合。

最后,千帕秒支持用于各种目的的消息:广播给所有玩家的游戏广播,通知单个玩家的游戏通知,玩家之间的通信。这些消息被组织成“棋盘”容器,用于管理排序和可见性;遵循项目序列模式,交换包括一个Get Board IDs请求、一个List of Board IDs响应,以及一系列Get Board请求和Board响应。

一旦客户端获得有关消息棋盘的信息,它就可以发出Get Message请求,通过插槽获取棋盘上的消息(因此,Get Message使用Get With ID and Slot基本帧);服务器将使用包含消息主题和正文、生成消息的回合以及对消息中提到的任何其他实体的引用的Message帧进行响应。除了在千帕秒中遇到的正常项目集(玩家、对象等)之外,还有一些特殊引用,包括消息优先级、玩家操作和订单状态。当然,客户端也可以使用Post Message帧(Messsage帧的载体)添加消息,并使用Remove Message帧(基于GetMessage帧)删除消息。

21.2.3. 对象、订单和资源

与宇宙交互过程的大部分是通过一组帧完成的,这些帧包含了对象、订单和资源的功能。

宇宙的物理状态(至少是玩家控制或可以看到的那部分)必须在连接时以及此后的每个回合获取。客户端通常会发出Get Object IDs请求(一个Get ID Sequence),服务器会用List of Object IDs响应进行回复。然后,客户端可以使用Get Object by ID请求请求有关单个对象的详细信息,这些请求将以Object帧进行回复,其中包含有关对象的详细信息(同样受玩家可见性的限制),例如它们的类型、名称、大小、位置、速度、包含的对象、适用的订单类型以及当前订单。协议还提供Get Object IDs by Position请求,允许客户端查找指定空间球体内的所有对象。

客户端通过发出Get Order Description IDs请求获得可能的订单集,并针对List of Order Description IDs响应中的每个ID,发出Get Order Description请求并接收Order Description响应。订单和订单队列本身的实现随着协议的历史而发生了显著变化。最初,每个对象都有一个订单队列。客户端会发出Order请求(包含订单类型、目标对象和其他信息),接收Outcome响应,其中详细说明了订单的预期结果,并在订单完成后接收包含实际结果的Result帧。

在第二个版本中,Order帧包含了Outcome帧的内容(因为根据订单描述,这不需要服务器的输入),并且Result帧完全被删除了。最新版本的协议将订单队列从对象中重构出来,并添加了Get Order Queue IDsList of Order Queue IDsGet Order QueueOrder Queue帧,这些帧的工作方式类似于消息和棋盘功能2Get OrderRemove Order帧(都是GetWithIDSlot请求)允许客户端分别访问和删除队列中的订单。Insert Order帧现在充当Order有效载荷的载体;这样做是为了允许另一个帧Probe Order,客户端在某些情况下使用它来获取本地使用信息。

资源描述也遵循项目序列模式:Get Resource Description IDs请求、List of Resource Description IDs响应,以及一系列Get Resource Description请求和Resource Description响应。

21.2.4. 设计操作

千帕秒协议中对设计的处理分为四个子类别的操作:类别、组件、属性和设计。

类别区分不同的设计类型。两种最常用的设计类型是飞船和武器。创建类别很简单,因为它只包含名称和描述;Category帧本身只包含这两个字符串。每个类别都通过规则集添加到设计存储中,使用Add Category请求,这是一个Category帧的载体。其余类别的管理以常见的项目序列模式进行,使用Get Category IDs请求和List of Category IDs响应。

组件由构成设计的不同部件和模块组成。这可以是飞船或导弹的船体,也可以是导弹所在的管子。组件比类别要复杂一些。Component帧包含以下信息

与组件相关的Requirements函数尤其值得注意。由于组件是构成飞船、武器或其他建造对象的部件,因此有必要确保在将它们添加到设计中时它们是有效的。Requirements函数验证添加到设计的每个组件是否符合之前添加的其他组件的规则。例如,在导弹和鱼雷战争中,如果飞船没有Alpha导弹发射管,就不可能在飞船中装载Alpha导弹。这种验证在客户端和服务器端都会发生,这就是为什么整个函数必须出现在协议帧中,以及为什么选择简洁的语言(TPCL,将在本章后面介绍)来编写它。

设计的全部属性通过Property帧进行通信。每个规则集都公开了一组在游戏中使用的属性。这些通常包括允许在飞船上安装的特定类型的导弹发射管的数量,或特定船体类型包含的装甲量。与Component帧一样,Property帧也使用TPCL。Property帧包含以下信息

属性的等级用于区分依赖项的层次结构。在TPCL中,函数不能依赖于等级小于或等于该属性的任何属性。这意味着如果有一个等级为1的Armor属性和一个等级为0的Invisibility属性,那么Invisibility属性不能直接依赖于Armor属性。这种等级是作为一种限制循环依赖的方法实现的。Calculate函数用于定义属性的显示方式,区分测量方法。导弹和鱼雷战争使用XML从游戏数据文件导入游戏属性。图21.2显示了该游戏数据中的一个示例属性。

<prop>
<CategoryIDName>Ships</CategoryIDName>
<rank value="0"/>
<name>Colonise</name>
<displayName>Can Colonise Planets</displayName>
<description>Can the ship colonise planets</description>
<tpclDisplayFunction>
    (lambda (design bits) (let ((n (apply + bits))) (cons n (if (= n 1) "Yes" "No")) ) )
</tpclDisplayFunction>
<tpclRequirementsFunction>
    (lambda (design) (cons #t ""))
</tpclRequirementsFunction>
</prop>

图21.2:示例属性

在这个例子中,我们有一个属于Ships类别的属性,等级为0。这个属性叫做Colonise,与飞船殖民星球的能力有关。快速看一下TPCL Calculate函数(这里列为tpclDisplayFunction)可以发现,这个属性根据相关飞船是否具有该能力输出“是”或“否”。以这种方式添加属性可以让规则集设计者对游戏的指标进行细粒度的控制,并能够轻松地比较它们并以对玩家友好的格式输出它们。

飞船、武器和其他游戏物品的实际设计是使用Design帧和相关帧创建和操作的。在所有当前规则集中,这些帧用于使用现有的组件和属性池构建飞船和武器。由于设计规则已经在TPCL Requirements函数(在属性和组件中)中处理,因此创建设计要简单一些。Design帧包含以下信息

这个帧与其他帧略有不同。最值得注意的是,由于设计是游戏中拥有的一种物品,因此与每个设计的拥有者相关联。设计还使用计数器跟踪其实例的数量。

21.2.5. 服务器管理

还提供了一个服务器管理协议扩展,允许远程实时控制支持服务器。标准用例是通过管理客户端(可能是类似 shell 的命令界面或 GUI 配置面板)连接到服务器,以更改设置或执行其他维护任务。但是,其他更专业的用途也是可能的,例如单人游戏的幕后管理。

与前面部分描述的游戏协议一样,管理客户端首先协商连接(在与正常游戏端口不同的端口上),并使用ConnectLogin请求进行身份验证。连接后,客户端可以接收来自服务器的日志消息并向服务器发出命令。

日志消息通过Log Message帧推送到客户端。这些帧包含严重级别和文本;根据上下文,客户端可以选择显示它接收到的所有、部分或没有日志消息。

服务器也可能会发出一个Command Update帧,指示客户端填充或更新其本地命令集;支持的命令在服务器对Get Command Description IDs帧的响应中向客户端公开。然后必须通过为每个命令发出一个Get Command Description帧来获取各个命令描述,服务器将用一个Command Description帧来响应。

这种交换在功能上与(事实上,最初也基于)主游戏协议中使用的订单帧非常相似。它允许将命令描述给用户并在本地进行一定程度的验证,从而最大限度地减少网络使用。管理协议是在游戏协议已经成熟的时候构思的;开发人员并没有从头开始,而是发现游戏协议中已经存在几乎满足需要的功能,并在相同的协议库中添加了代码。

21.3. 支持功能

21.3.1. 服务器持久性

Thousand Parsec 游戏,就像回合制策略类型中的许多游戏一样,有可能持续相当长的时间。除了经常运行时间比玩家物种的昼夜节律长得多之外,在此期间,服务器进程可能会由于各种原因被过早终止。为了让玩家能够从他们离开的地方继续游戏,Thousand Parsec 服务器通过将整个宇宙状态(甚至多个宇宙)存储在数据库中来提供持久性。这种功能也以类似的方式用于保存单人游戏,这将在本节后面更详细地介绍。

旗舰服务器tpserver-cpp提供了一个抽象的持久性接口和一个模块化的插件系统,以允许使用各种数据库后端。在撰写本文时,tpserver-cpp随附了用于 MySQL 和 SQLite 的模块。

抽象的Persistence类描述了允许服务器保存、更新和检索游戏各种元素的功能(如“星系帝国的解剖结构”部分所述)。数据库从服务器代码中游戏状态发生变化的各个地方不断更新,无论服务器终止或崩溃的点在哪里,当服务器从保存的数据中重新启动时,所有到该点的信息都应该恢复。

21.3.2. Thousand Parsec 组件语言

Thousand Parsec 组件语言 (TPCL) 允许客户端在没有服务器交互的情况下创建本地设计,从而允许对设计的属性、构成和有效性进行即时反馈。这使得玩家可以交互地创建新的星舰类别,例如,通过根据可用技术定制结构、推进系统、仪器、防御系统、武器等等。

TPCL 是 Scheme 的一个子集,有一些小的变化,但与 Scheme R5RS 标准足够接近,任何兼容的解释器都可以使用。最初选择 Scheme 是因为它简单、有许多先例将其用作嵌入式语言、在许多其他语言中实现的解释器可用,最重要的是,对于一个开源项目来说,关于使用它和开发解释器的大量文档。

考虑以下 TPCL 中Requirements函数的示例,它由组件和属性使用,并将包含在服务器端的规则集中,并通过游戏协议传达给客户端

(lambda (design)
  (if (> (designType.MaxSize design) (designType.Size design))
      (if (= (designType.num-hulls design) 1)
          (cons #t "")
          (cons #f "Ship can only have one hull")
      )
      (cons #f "This many components can't fit into this Hull")
  )
)

熟悉 Scheme 的读者无疑会发现这段代码很容易理解。游戏(客户端和服务器)使用它来检查其他组件属性(MaxSizeSizeNum-Hulls),以验证该组件是否可以添加到设计中。它首先验证组件的Size是否在设计的最大尺寸范围内,然后确保设计中没有其他船体(后一个测试告诉我们这是来自船体Requirements函数)。

21.3.3. BattleXML

在战争中,每一场战斗都很重要,从深空中轻型武装侦察机中队的短暂遭遇战,到两个旗舰舰队在首都星球上空天空中的巨大最后决战。在 Thousand Parsec 框架中,战斗的细节在规则集中处理,并且没有关于战斗细节的明确客户端功能,通常,玩家将通过消息被告知战斗的开始和结果,并且对象将发生相应的变化(例如,摧毁的船只被移除)。尽管玩家的注意力通常集中在更高级别上,但在战斗机制复杂的规则集中,更详细地检查战斗可能会证明是有益的(或者至少是娱乐性的)。

这就是 BattleXML 的用武之地。战斗数据分为两个主要部分:媒体定义,它提供有关将要使用的图形的详细信息;战斗定义,它指定战斗期间实际发生的事件。这些旨在被战斗查看器读取,Thousand Parsec 目前有两个:一个是在 2D 中,另一个是在 3D 中。当然,由于战斗的性质完全是规则集的一个特征,因此规则集代码负责实际生成 BattleXML 数据。

媒体定义与查看器的性质相关联,并且存储在包含 XML 数据及其引用的任何图形或模型文件的目录或档案中。数据本身描述了每个船舶(或其他物体)类型应该使用什么媒体、其动作的动画(例如发射和死亡)、以及其武器的媒体和细节。文件位置被假定为相对于 XML 文件本身,并且不能引用父目录。

战斗定义与查看器和媒体无关。首先,它描述了战斗开始时双方的一系列实体,具有唯一的标识符以及名称、描述和类型等信息。然后,描述了战斗的每一轮:物体移动、武器发射(包括来源和目标)、物体损坏、物体死亡以及日志消息。使用多少细节来描述战斗的每一轮是由规则集决定的。

21.3.4. 元服务器

寻找一个可以玩 Thousand Parsec 公开服务器就像在深空中找到一个孤独的隐形侦察机一样,这是一个令人生畏的前景,如果一个人不知道在哪里寻找。幸运的是,公共服务器可以向元服务器宣布自己,元服务器的位置作为中心枢纽,应该被玩家熟知。

目前的实现是metaserver-lite,一个 PHP 脚本,它位于像 Thousand Parsec 网站这样的中央位置。支持服务器发送一个 HTTP 请求,指定更新操作,并包含类型、位置(协议、主机和端口)、规则集、玩家数量、对象数量、管理员和其他可选信息。服务器列表在指定的超时时间后过期(默认情况下为 10 分钟),因此服务器应该定期更新元服务器。

然后,当脚本被调用时,没有指定任何操作,它可以用来将服务器列表与详细信息嵌入到网站中,呈现可点击的 URL(通常使用tp://方案名称)。或者,badge 操作以紧凑的“badge”格式呈现服务器列表。

客户端可以使用 get 操作向元服务器发出请求,以获取可用服务器的列表。在这种情况下,元服务器将为列表中的每个服务器返回一个或多个Game帧给客户端。在tpclient-pywx中,结果列表在初始连接窗口中的服务器浏览器中显示。

21.3.5. 单人模式

Thousand Parsec 从一开始就被设计为支持网络多人游戏。但是,没有什么可以阻止玩家启动一个本地服务器,连接几个 AI 客户端,然后跳入一个自定义的单人宇宙,准备征服。该项目定义了一些标准的元数据和功能,以支持简化此过程,使设置变得像运行 GUI 向导或双击场景文件一样容易。

该功能的核心是一个 XML DTD,它指定了有关每个组件(例如服务器、AI 客户端、规则集)的功能和属性的元数据的格式。组件包附带一个或多个这样的 XML 文件,最终所有这些元数据都被聚合到一个关联数组中,该数组分为两个主要部分:服务器和 AI 客户端。在服务器的元数据中,通常会找到一个或多个规则集的元数据,它们位于这里,因为即使规则集可能在多个服务器上实现,一些配置细节可能会有所不同,因此通常需要为每个实现提供单独的元数据。每个组件条目包含以下信息

强制参数不可由玩家配置,通常是允许组件在本地单人上下文中正常运行的选项。玩家参数有自己的格式,指示诸如名称和描述、数据类型、默认值和值的范围,以及附加到主命令字符串的格式字符串等详细信息。

虽然特殊情况是可能的(例如,针对规则集特定客户端的预设游戏配置),但构建单人游戏的典型过程包括选择一组兼容的组件。对客户端的选择是隐式的,因为玩家已经启动了一个客户端来玩游戏,一个设计良好的客户端遵循以用户为中心的流程来设置其余部分。下一个自然的选择是规则集,因此玩家会被呈现一个列表,此时,无需理会服务器详细信息。如果选择的规则集由多个已安装的服务器实现(这可能是一种罕见的情况),则会提示玩家选择一个,否则,将自动选择相应的服务器。接下来,会提示玩家为规则集和服务器配置选项,合理的默认值将从元数据中获取。最后,如果安装了任何兼容的 AI 客户端,则会提示玩家配置一个或多个 AI 客户端与之对抗。

在游戏配置完成后,客户端使用元数据中的命令字符串信息,启动本地服务器,并使用相应的配置参数(包括规则集、其参数以及它添加到服务器配置中的任何参数)。一旦它验证了服务器正在运行并接受连接,也许使用前面讨论过的管理协议扩展,它将以类似的方式启动每个指定的 AI 客户端,并验证它们是否已成功连接到游戏。如果一切顺利,客户端将连接到服务器,就像它连接到在线游戏一样,玩家可以开始探索、交易、征服以及其他各种可能性。

单人游戏功能的另一个非常重要的用途是保存和加载游戏,以及(或多或少等同于)加载可立即进行的游戏场景。在这种情况下,保存数据(可能,但并非必要,是一个单独的文件)将单人游戏配置数据与游戏本身的持久性数据存储在一起。只要玩家系统上安装了所有兼容版本的必要组件,加载保存的游戏或场景将完全自动进行。特别是场景,为游戏提供了吸引人的一键式入口。虽然千年秒速目前没有专门的场景编辑器或带有编辑模式的客户端,但概念是提供一些方法来在规则集正常运行之外构建持久性数据,并验证其一致性和兼容性。

到目前为止,对该功能的描述相当抽象。更具体地说,Python 客户端辅助库 `libtpclient-py` 目前是千年秒速项目中单人游戏机制唯一完整实现的地方。该库提供了 `SinglePlayerGame` 类,该类在实例化时会自动聚合系统上所有可用的单人游戏元数据(当然,对于 XML 文件应安装在哪个平台上,存在一些准则)。然后,客户端可以查询该对象以获取有关可用组件的各种信息;服务器、规则集、AI 客户端和参数以字典(Python 的关联数组)的形式存储。按照上面概述的一般游戏构建过程,典型的客户端可能会执行以下操作

  1. 通过 `SinglePlayerGame.rulesets` 查询可用规则集列表,并通过设置 `SinglePlayerGame.rname` 使用选定的规则集配置该对象。
  2. 通过 `SinglePlayerGame.list_servers_with_ruleset` 查询实现该规则集的服务器列表,如果需要,提示用户选择一个,并通过设置 `SinglePlayerGame.sname` 使用选择的(或唯一的)服务器配置该对象。
  3. 分别通过 `SinglePlayerGame.list_rparams` 和 `SinglePlayerGame.list_sparams` 获取服务器和规则集的参数集,并提示玩家配置它们。
  4. 通过 `SinglePlayerGame.list_aiclients_with_ruleset` 查找支持该规则集的可用 AI 客户端,并提示玩家使用通过 `SinglePlayerGame.list_aiparams` 获取的参数配置一个或多个 AI 客户端。
  5. 通过调用 `SinglePlayerGame.start` 启动游戏,如果成功,该方法将返回要连接的 TCP/IP 端口。
  6. 最终,通过调用 `SinglePlayerGame.stop` 结束游戏(并终止任何已启动的服务器和 AI 客户端进程)。

千年秒速的旗舰客户端 `tpclient-pywx` 提供了一个用户友好的向导,该向导遵循这样的过程,最初提示加载保存的游戏或场景文件。为该向导开发的以用户为中心的流程是该项目开源开发过程产生的良好设计的示例:开发人员最初提出了一个非常不同的流程,该流程更符合幕后工作方式,但社区讨论和一些协作开发产生了对玩家更有用的结果。

最后,保存的游戏和场景目前在实践中在 `tpserver-cpp` 中实现,`libtpclient-py` 中有支持功能,`tpclient-pywx` 中有接口。这是通过使用 SQLite 的持久性模块实现的,SQLite 是一个公有领域的开源 RDBMS,它不需要外部进程并将数据库存储在一个文件中。服务器通过强制参数配置为使用 SQLite 持久性模块(如果可用),并且与往常一样,数据库文件(位于临时位置)在整个游戏过程中不断更新。当玩家选择保存游戏时,数据库文件被复制到指定的位置,并添加一个包含单人游戏配置数据的特殊表。对于读者来说,如何随后加载它应该相当清楚。

21.4. 经验教训

广泛的千年秒速框架的创建和发展为开发人员提供了很多机会来回顾并评估沿途做出的设计决策。最初的核心开发人员(Tim Ansell 和 Lee Begg)从头开始构建了原始框架,并与我们分享了有关启动类似项目的建议。

21.4.1. 成功之处

千年秒速开发的一个主要关键是决定定义和构建框架的子集,然后进行实现。这种迭代和增量设计过程允许框架有机地发展,并无缝添加新功能。这直接导致了对千年秒速协议进行版本控制的决定,这被认为是框架的许多重大成功的功劳。对协议进行版本控制允许框架随着时间的推移而发展,在整个过程中支持新的游戏玩法。

在开发如此广泛的框架时,重要的是要对目标和迭代采取非常短期的办法。短期迭代,以几周为单位进行小版本发布,允许项目快速推进,并立即获得回报。实现的另一个成功之处是客户端-服务器模型,该模型允许客户端独立于任何游戏逻辑进行开发。将游戏逻辑与客户端软件分离对于千年秒速的整体成功至关重要。

21.4.2. 失败之处

千年秒速框架的一个主要缺陷是决定使用二进制协议。正如你可能想象的,调试二进制协议不是一项有趣的任务,这会导致许多长时间的调试过程。我们强烈建议将来没有人走这条路。该协议也变得过于灵活;在创建协议时,重要的是只实现所需的基本功能。

我们的迭代有时变得太大。在开源开发计划中管理如此庞大的框架时,重要的是在每次迭代中添加少量功能,以保持开发的顺利进行。

21.4.3. 结论

就像一个建造驳船在轨道建造场检查巨大的原型战舰的骨架一样,我们也检查了千年秒速架构的各种细节。虽然从一开始,灵活性和可扩展性的通用设计标准就一直萦绕在开发人员的脑海中,但从框架的历史来看,很明显,只有充满新鲜想法和观点的开源生态系统才能创造出如此大量的可能性,同时保持功能性和凝聚力。这是一个雄心勃勃的项目,就像开源领域中的许多同行一样,还有很多工作要做;我们希望并期望随着时间的推移,千年秒速将继续发展并扩展其功能,同时在此基础上开发出新的、更复杂的游戏。毕竟,一千秒速的旅程始于第一步。

脚注

  1. 千年秒速灵感的一些优秀商业案例包括 *VGA Planets* 和 *Stars!*,以及 *星际霸主*、*银河文明* 和 *星际帝国* 系列。对于不熟悉这些作品的读者来说,*文明* 系列是同一游戏风格的流行示例,尽管是在不同的环境中。还存在一些实时 4X 游戏,例如 *银河帝国* 和 *太阳帝国的罪恶*。
  2. 实际上,情况正好相反:消息和版块是在协议的第二个版本中从订单派生出来的。