开源应用程序架构(第一卷)
战役 for Wesnoth

理查德·西穆卡和戴维·怀特

编程往往被认为是一项直截了当的解决问题活动;开发人员有一个需求,并编写一个解决方案。美感通常取决于技术实现的优雅或有效性;本书充满了优秀示例。然而,除了其直接的计算功能外,代码可以对人们的生活产生深远的影响。它可以激励人们参与并创造新的内容。不幸的是,存在一些严重的障碍阻碍了个人参与项目。

大多数编程语言需要大量的技术专业知识才能使用,这对于许多人来说是难以企及的。此外,提高代码的可访问性在技术上很困难,对于许多程序来说也是不必要的。它很少转化为简洁的编码脚本或巧妙的编程解决方案。实现可访问性需要在项目和程序设计中进行大量的预先思考,这往往与正常的编程标准背道而驰。此外,大多数项目依赖于一批经验丰富的专业人员,这些专业人员预计能够在相当高的水平上运行。他们不需要额外的编程资源。因此,代码可访问性成为了事后诸葛亮,即使考虑也是如此。

我们的项目“战役 for Wesnoth”试图从一开始就解决这个问题。该程序是一款基于回合制的奇幻策略游戏,采用基于 GPL2 许可的开源模型制作。它取得了中等程度的成功,在撰写本文时已下载超过 400 万次。虽然这是一个令人印象深刻的指标,但我们认为我们项目真正的魅力在于开发模型,它允许来自不同技能水平的一群志愿者以有效的方式进行互动。

提高可访问性不是开发人员设定的一个模糊目标,而是被视为项目生存的必要条件。Wesnoth 的开源方法意味着该项目无法立即期望大量高技能的开发人员。使该项目对大量具有不同技能水平的贡献者开放,将确保其长期生存能力。

我们的开发人员试图从其最早的迭代中就为扩大可访问性奠定基础。这将对编程架构的各个方面产生不可否认的影响。重大决定主要出于这个目标。本章将对我们的程序进行深入研究,重点关注提高可访问性的努力。

本章的第一部分概述了项目的编程,涵盖其语言、依赖项和架构。第二部分将重点介绍 Wesnoth 独特的数据存储语言,称为 Wesnoth 标记语言 (WML)。它将解释 WML 的具体功能,特别强调其对游戏内单位的影响。下一节将介绍多人游戏实现和外部程序。本章最后将对我们的结构和扩大参与的挑战提出一些总结性的意见。

25.1. 项目概述

Wesnoth 的核心引擎是用 C++ 编写的,在本文发表时总共约 200,000 行代码。这代表了核心游戏引擎,大约占代码库的一半,不包括任何内容。该程序还允许游戏内容在一种称为 Wesnoth 标记语言 (WML) 的独特数据语言中定义。游戏附带另外 250,000 行 WML 代码。该比例在项目存在期间发生了变化。随着程序的成熟,在 C++ 中硬编码的游戏内容越来越多地被重写,以便 WML 可以用来定义其操作。 图 25.1 简要地描述了程序的架构;绿色区域由 Wesnoth 开发人员维护,而白色区域是外部依赖项。

[Program Architecture]

图 25.1:程序架构

总的来说,该项目尝试在大多数情况下最小化依赖项,以便最大程度地提高应用程序的可移植性。这还有助于降低程序的复杂性,并减少开发人员学习大量第三方 API 的细微差别的需要。同时,谨慎使用一些依赖项实际上可以达到相同的效果。例如,Wesnoth 使用简单直接媒体层 (SDL) 进行视频、I/O 和事件处理。之所以选择它,是因为它易于使用,并为许多平台提供了通用的 I/O 接口。这使它能够移植到各种平台,而不是在不同平台上对特定 API 进行编码。然而,这也有代价;很难利用一些平台特定的功能。SDL 还附带了一组库,Wesnoth 用于各种目的

此外,Wesnoth 还使用几个其他库

在整个 Wesnoth 引擎中,WML 对象的使用(即带有子节点的字符串字典)相当普遍。许多对象可以从 WML 节点构建,也可以将自身序列化为 WML 节点。引擎的某些部分以这种 WML 字典格式保留数据,直接对其进行解释,而不是将其解析为 C++ 数据结构。

Wesnoth 使用几个重要的子系统,其中大多数尽可能自包含。这种分段结构对可访问性有利。感兴趣的方可以轻松地在特定区域中处理代码,并在不破坏程序其余部分的情况下引入更改。主要细分包括

还有用于控制游戏流程不同部分的不同模块

“玩游戏”模块和主显示模块是 Wesnoth 中最大的模块。它们的目的是最不确定的,因为它们的功能一直在变化,因此很难为其制定明确的规范。因此,这些模块在程序的历史上经常面临 Blob 反模式的风险,即变得庞大且占主导地位,没有明确的行为。显示和玩游戏模块中的代码会定期进行审查,以查看其中是否有任何内容可以分离为单独的模块。

还有一些辅助功能是整个项目的一部分,但与主程序分开。这包括一个用于促进多人网络游戏的多人服务器,以及一个内容服务器,允许用户将自己的内容上传到公共服务器并与他人共享。两者都是用 C++ 编写的。

25.2. Wesnoth 标记语言

作为一种可扩展的游戏引擎,Wesnoth 使用一种简单的数据语言来存储和加载所有游戏数据。虽然最初考虑过 XML,但我们决定我们想要一些对非技术用户更友好的东西,并且在使用视觉数据方面更宽松一些。因此,我们开发了自己的数据语言,称为 Wesnoth 标记语言 (WML)。它是为最非技术的用户设计的:希望即使是发现 Python 或 HTML 令人畏惧的用户也能理解 WML 文件。所有 Wesnoth 游戏数据都存储在 WML 中,包括单位定义、战役、场景、GUI 定义和其他游戏逻辑配置。

WML 与 XML 共享相同的基本属性:元素和属性,尽管它不支持元素内的文本。WML 属性仅表示为将字符串映射到字符串的字典,程序逻辑负责对属性进行解释。WML 的一个简单示例是游戏中精灵战士单位的修剪定义

[unit_type]
    id=Elvish Fighter
    name= _ "Elvish Fighter"
    race=elf
    image="units/elves-wood/fighter.png"
    profile="portraits/elves/fighter.png"
    hitpoints=33
    movement_type=woodland
    movement=5
    experience=40
    level=1
    alignment=neutral
    advances_to=Elvish Captain,Elvish Hero
    cost=14
    usage=fighter
    {LESS_NIMBLE_ELF}
    [attack]
        name=sword
        description=_"sword"
        icon=attacks/sword-elven.png
        type=blade
        range=melee
        damage=5
        number=4
    [/attack]
[/unit_type]

由于国际化在 Wesnoth 中很重要,因此 WML 对其提供直接支持:具有下划线前缀的属性值是可翻译的。任何可翻译的字符串在 WML 被解析时都会使用 GNU gettext 转换为字符串的翻译版本。

Wesnoth 并没有使用许多不同的 WML 文档,而是选择将所有主要游戏数据以单个文档的形式呈现给游戏引擎。这允许单个全局变量保存文档,并且在游戏加载时,例如所有单位定义都是通过在 units 元素中查找名为 unit_type 的元素来加载的。

虽然所有数据都存储在单个概念 WML 文档中,但如果将所有数据都放在单个文件中,则会很麻烦。因此,Wesnoth 支持在解析之前对所有 WML 运行的预处理器。此预处理器允许一个文件包含另一个文件的内容,或包含整个目录。例如

{gui/default/window/}

将包含 gui/default/window/ 中的所有 .cfg 文件。

由于 WML 可能变得非常冗长,因此预处理器还允许定义宏来压缩内容。例如,精灵战士定义中的 {LESS_NIMBLE_ELF} 调用是对宏的调用,该宏在某些条件下(例如,当它们驻扎在森林中时)使某些精灵单位的敏捷性降低

#define LESS_NIMBLE_ELF
    [defense]
        forest=40
    [/defense]
#enddef

这种设计的好处在于使引擎与 WML 文档如何分解为文件无关。WML 作者负责决定如何将所有游戏数据结构化并划分为不同的文件和目录。

当游戏引擎加载 WML 文档时,它还会根据各种游戏设置定义一些预处理器符号。例如,Wesnoth 战役可以定义不同的难度设置,每个难度设置都会导致定义不同的预处理器符号。例如,一种常见的改变难度的方法是改变给予对手的资源数量(用金币表示)。为了促进这一点,定义了一个像这样的 WML 宏

#define GOLD EASY_AMOUNT NORMAL_AMOUNT HARD_AMOUNT
  #ifdef EASY
    gold={EASY_AMOUNT}
  #endif
  #ifdef NORMAL
    gold={NORMAL_AMOUNT}
  #endif
  #ifdef HARD
    gold={HARD_AMOUNT}
  #endif
#enddef

此宏可以使用例如 {GOLD 50 100 200} 在对手的定义中调用,以定义对手根据难度级别拥有的金币数量。

由于 WML 是根据条件进行处理的,如果提供给 WML 文档的任何符号在 Wesnoth 引擎执行期间发生变化,则必须重新加载和处理整个 WML 文档。例如,当用户开始游戏时,WML 文档被加载,并且可用的战役以及其他内容被加载。但是,如果用户选择开始一个战役并选择一个特定的难度级别 - 例如简单 - 那么整个文档将必须重新加载,并定义 EASY。

这种设计很方便,因为单个文档包含所有游戏数据,并且符号可以轻松配置 WML 文档。但是,作为一个成功的项目,Wesnoth 的内容越来越多,包括许多可下载的内容 - 所有这些最终都被插入到核心文档树中 - 这意味着 WML 文档的大小为数兆字节。这已经成为 Wesnoth 的性能问题:在某些计算机上加载文档可能需要长达一分钟的时间,这会导致每次需要重新加载文档时游戏内的延迟。此外,它会使用大量内存。一些措施被用来解决这个问题:当一个战役被加载时,它在预处理器中定义了一个特定于该战役的符号。这意味着任何特定于该战役的内容都可以使用 #ifdef 进行定义,以便只有在需要该战役时才使用。

此外,Wesnoth 使用缓存系统来缓存给定一组密钥定义的 WML 文档的完全预处理版本。自然,这个缓存系统必须检查所有 WML 文件的时间戳,以便如果任何文件发生更改,则会重新生成缓存的文档。

25.3. Wesnoth 中的单位

Wesnoth 的主角是它的单位。一个精灵战士和一个精灵萨满可能会与一个巨魔战士和一个兽人步兵战斗。所有单位共享相同的基本行为,但许多单位拥有特殊的能力,可以改变正常的游戏流程。例如,巨魔在每一回合都会恢复一些生命值,精灵萨满可以用缠绕根来减缓对手的速度,而狼人则在森林中是隐形的。

在引擎中如何最好地表示这一点?在 C++ 中创建一个基本 unit 类,并从该类派生不同类型的单位,这很诱人。例如,一个 wose_unit 类可以从 unit 类派生,而 unit 类可以有一个虚拟函数 bool is_invisible() const,它返回 false,而 wose_unit 类会覆盖它,如果该单位碰巧在森林中,则返回 true。

对于规则集有限的游戏来说,这种方法效果相当好。不幸的是,Wesnoth 是一款相当大的游戏,这种方法不容易扩展。如果一个人想在这种方法下添加一种新的单位类型,则需要在游戏中添加一个新的 C++ 类。此外,它不允许很好地组合不同的特征:如果你有一个单位可以再生、可以用网减缓敌人速度,并且在森林中是隐形的,怎么办?你将不得不编写一个全新的类,它会复制其他类中的代码。

Wesnoth 的单位系统根本不使用继承来完成这项任务。相反,它使用 unit 类来表示单位的实例,以及 unit_type 类,它表示所有特定类型单位共享的不可变特征。unit 类对它所是的对象的类型有一个引用。所有可能的 unit_type 对象都存储在一个全局持有的字典中,该字典在加载主 WML 文档时被加载。

一个单位类型有一个列表,其中包含该单位拥有的所有能力。例如,巨魔拥有“再生”能力,这使得它在每一回合都能恢复生命值。蜥蜴游骑兵拥有“游骑兵”能力,这使得它可以穿过敌人的阵线。对这些能力的识别是内置在引擎中的 - 例如,寻路算法会检查一个单位是否设置了“游骑兵”标志,以查看它是否可以自由地穿过敌人的阵线。这种方法允许个人通过只编辑 WML 来添加新的单位,这些单位具有由引擎创建的任何能力组合。当然,它不允许在不修改引擎的情况下添加全新的能力和单位行为。

此外,Wesnoth 中的每个单位都可以拥有任意数量的攻击方式。例如,一个精灵弓箭手有一个远程弓箭攻击,还有一个近战剑攻击。每个攻击都会造成不同的伤害量和特征。为了表示攻击,有一个 attack_type 类,每个 unit_type 实例都有一个可能的 attack_types 列表。

为了使每个单位更有特点,Wesnoth 有一个称为特性的功能。在招募时,大多数单位会随机从预定义列表中分配两个特性。例如,一个强壮的单位在近战攻击时会造成更多伤害,而一个聪明的单位则需要更少的经验才能“升级”。此外,单位有可能在游戏中获得装备,使他们变得更加强大。例如,可能有一把剑可以让单位拾取,这会让他们的攻击造成更多伤害。为了实现特性和装备,Wesnoth 允许对单位进行修改,这些修改是对单位统计数据的 WML 定义的更改。修改甚至可以应用于某些类型的攻击。例如,强壮特性使强壮单位在近战攻击时造成更多伤害,但在使用远程攻击时不会造成更多伤害。

允许使用 WML 完全配置单位行为将是一个值得称道的目标,因此考虑为什么 Wesnoth 从未实现这样的目标是有益的。如果 WML 要允许任意的单位行为,它需要比现在灵活得多。WML 不再是一个面向数据的语言,而是必须扩展成一个完整的编程语言,这对许多有抱负的贡献者来说将是令人生畏的。

此外,Wesnoth 的 AI(使用 C++ 开发)识别游戏中存在的技能。它会考虑到再生、隐形等等,并尝试操纵它的单位以充分利用这些不同的技能。即使可以使用 WML 创建一个单位技能,也很难使 AI 足够复杂,以识别这个技能来利用它。实现一个技能,但 AI 没有考虑到它,将不是一个令人满意的实现。类似地,用 WML 实现一个技能,然后必须修改 C++ 中的 AI 来考虑这个技能,将会很尴尬。因此,拥有可以用 WML 定义的单位,但将技能硬编码到引擎中,被认为是符合 Wesnoth 特定要求的合理的折衷方案。

25.4. Wesnoth 的多人游戏实现

Wesnoth 的多人游戏实现使用尽可能简单的方法来实现 Wesnoth 中的多人游戏。它试图减轻对服务器进行恶意攻击的可能性,但没有认真尝试防止作弊。Wesnoth 游戏中进行的任何移动 - 移动单位、攻击敌人、招募单位等等 - 都可以保存为 WML 节点。例如,移动单位的命令可能被保存到 WML 中,如下所示

[move]
    x="11,11,10,9,8,7"
    y="6,7,7,8,8,9"
[/move]

这显示了单位根据玩家命令遵循的路径。然后游戏有一个工具来执行任何给定的 WML 命令。这非常有用,因为它意味着可以通过存储游戏的初始状态以及所有后续命令来保存完整的重播。能够重播游戏对于玩家观察彼此的游戏以及帮助某些类型的错误报告都很有用。

我们认为,社区将尝试专注于为 Wesnoth 的网络多人游戏实现提供友好、休闲的游戏。该项目不会努力阻止作弊,而不是与试图破坏作弊预防系统的反社会黑客进行技术斗争。对其他多人游戏的分析表明,竞争排名系统是反社会行为的主要来源。故意阻止服务器上的此类功能极大地减少了个人作弊的动机。此外,主持人试图鼓励积极的游戏社区,在这个社区中,个人与其他玩家建立个人关系并与他们一起玩游戏。这更强调关系而不是竞争。这些努力的结果被认为是成功的,因为迄今为止,恶意破解游戏的努力基本上是孤立的。

Wesnoth 的多人游戏实现包括一个典型的客户端-服务器基础设施。一个名为 wesnothd 的服务器接受来自 Wesnoth 客户端的连接,并向客户端发送可用游戏的摘要。Wesnoth 会向玩家显示一个“大厅”,玩家可以选择加入游戏或创建一个新的游戏供其他人加入。一旦玩家处于游戏中并且游戏开始,每个 Wesnoth 实例都会生成 WML 命令来描述玩家执行的动作。这些命令被发送到服务器,然后服务器将它们转发到游戏中所有其他客户端。因此,服务器将充当非常薄、简单的中继。重播系统在其他客户端上使用,以执行 WML 命令。由于 Wesnoth 是一款回合制游戏,因此 TCP/IP 用于所有网络通信。

此系统还允许观察者轻松观看游戏。观察者可以加入正在进行的游戏,在这种情况下,服务器将发送代表游戏初始状态的 WML,然后是自游戏开始以来执行的所有命令的历史记录。这允许新的观察者了解游戏的当前状态。他们可以看到游戏的历史记录,尽管观察者需要一些时间才能进入游戏的当前位置 - 命令的历史记录可以快速转发,但仍然需要一些时间。另一种方法是让其中一个客户端生成游戏当前状态的快照作为 WML 并将其发送给新的观察者;但是,这种方法会使客户端承担基于观察者的开销,并可能通过让许多观察者加入游戏来促进拒绝服务攻击。

当然,由于 Wesnoth 客户端不会彼此共享任何游戏状态,只发送命令,因此他们必须就游戏的规则达成一致。服务器按版本进行细分,只有使用相同游戏版本的玩家才能进行交互。如果玩家的客户端与其他客户端不同步,他们会立即收到警报。这也是一个防止作弊的有用系统。虽然玩家可以通过修改他们的客户端来作弊,但这很容易,但任何版本之间的差异都会立即被识别出来,以便玩家可以处理它。

25.5. 结论

我们认为,作为程序的 Wesnoth 之战的美妙之处在于,它如何使各种各样的个人能够访问编码。为了实现这一目标,该项目经常做出一些妥协,这些妥协在代码中看起来并不优雅。应该注意的是,该项目的许多更有才华的程序员都对 WML 的低效语法感到皱眉。然而,这种妥协使该项目取得了巨大的成功。如今,Wesnoth 可以吹嘘拥有数百个用户制作的战役和时代,这些战役和时代主要由几乎没有编程经验的用户创建。此外,它激发了很多人将编程作为职业,并将该项目作为学习工具。这些都是很少有程序能比拟的有形的成就。

读者应该从 Wesnoth 的努力中吸取的一个关键教训是,要考虑技术水平较低的程序员所面临的挑战。这需要意识到什么阻碍了贡献者实际执行编码和发展他们的技能。例如,一个人可能想为该项目做出贡献,但没有编程技能。像 emacsvim 这样的专用技术编辑器具有相当大的学习曲线,这对这样的人来说可能是令人生畏的。因此,WML 被设计成允许使用简单的文本编辑器打开其文件,从而为任何人提供贡献的工具。

然而,提高代码库的可访问性并非易事。没有硬性规定可以用来提高代码的可访问性。它需要在不同的考虑因素之间取得平衡,而这些因素可能会带来社区必须意识到的负面后果。这在该项目处理依赖关系的方式中体现得淋漓尽致。在某些情况下,依赖关系实际上会增加参与的门槛,而在其他情况下,它们可以让人们更容易地做出贡献。每个问题都必须根据具体情况进行考虑。

我们也应该谨慎,不要夸大 Wesnoth 一些成功之处。该项目享有某些优势,其他项目难以复制。使代码更广泛地为人所用,部分归功于该项目的设置。作为开源项目,Wesnoth 在这方面具有几个优势。在法律上,GNU 许可证允许人们打开一个现有的文件,了解它的工作原理并进行修改。在这样的文化中,鼓励个人进行实验、学习和分享,这可能不适合其他项目。尽管如此,我们希望某些元素可能对所有开发人员有用,并帮助他们在编码过程中找到美丽。