第25章 韦诺之战
作者:Richard Shimooka, David White
译者:谢路云
状态:完成
原文链接:http://www.aosabook.org/en/wesnoth.html
编程往往被简单地看作一种解决问题的行为,开发者根据需求编码得到一个解决方案。对代码优美程度的判断一般来自于技术实现上的优雅或者效率,而这本书(《开源软件架构》)中的项目就是它们之中的杰出例子。除了计算,代码还对公众的生活产生了深远的影响。它能够激励人们参与并创造新的事物。但不幸的是,大家在参与各种项目时仍然会遇到很高的门槛。
大多数编程语言都需要相当的技术专业知识才能使用,这对许多人来说遥不可及。此外,让所有人都能编程不仅从技术上是困难的,而且对于许多项目也没有必要。这样做并不一定能够得到简洁的代码或是聪明的解决方案。提高项目的参与程度需要开发者在项目和程序的设计时有远见,而这经常是和正常的编程习惯相违背的。再者,大多数项目的核心都是一组熟练的高水平专业程序员。他们并不需要外部资源的帮助。因此,项目的可参与性就变成了可有可无的东西,甚至从没有被考虑过。
我们的项目“韦诺之战”试图从源头上解决这个问题。它是一个基于GPL2许可证的在开源模式下开发的回合制奇幻战略游戏,它相当成功,截至这篇文章发表时已经被下载了超过四百万次。虽然这个数据很可观,但我们认为这个项目真正的出彩之处在于其开发模式凝聚了一大批能力水准各异的志愿者。
提高可参与性并不是我们开发者设立的一个模糊的目标,而是被视为这个项目成败的关键。开源意味着韦诺之战不可能立刻就吸引来大量高质量的开发者。吸引更多掌握不同技能的贡献者参与项目才能保证项目的长久活力。
我们的开发者从第一个迭代起就开始为扩大项目的参与程度而努力。这不可避免的会对项目架构的各个方面都产生一些影响。项目中的大部分决策在制定的过程中也都会考虑到这个目标。本章会深入地讲解我们的项目,尤其是我们在扩大项目参与度方面所进行的努力。
本章的第一部分概括了项目的代码,包括编程语言、依赖和架构。第二部分将集中介绍韦诺之战独特的数据存储语言,叫做“韦诺标记语言”(WML)。这部分将说明WML的功能,特别是对于游戏单位的影响。之后介绍的是多人游戏的实现以及一些外围项目。章节的最后会给出一些我们在架构和拓展项目参与度方面观察到的结论。
25.1项目概况
韦诺之战的核心引擎是用C++写的,现在总共约20万行代码。这只是游戏的核心引擎,不包含任何游戏内容,约占整个代码库的一半。我们的程序接受由一种叫做“韦诺标记预言”(WML)的独特的数据语言所定义的游戏内容。游戏在发布时还包含约25万行WML代码。这个比例在项目中还在不断升高。随着项目的成熟,硬编码在C++中的游戏内容已经越来越多地被重写为WML所定义的操作。图25.1给出了项目的大致结构。绿色的部分是韦诺之战的开发者们维护的,而白色的部分则是由外部的参与者们维护的。
图25.1:项目架构
总体而言,我们项目在大多数情况下都会尽量将依赖最小化以最大化应用程序的可移植性。这么做的间接好处是降低了程序的复杂度,并且开发者也无需再学习大量第三方API之间的琐碎区别。但同时,谨慎的使用一些第三方库也能达到相同的效果。例如,韦诺之战在处理视频、I/O和事件中使用了SDL库。选择它是因为它使用方便且提供了一个跨平台的公共I/O接口。这使得韦诺之战能够被移植到许多平台,而无需为不同的平台专门编写API。当然,这也是有代价的,那就是很难再利用一些平台专有的特性。韦诺之战还使用了SDL附带的一系列类库:
- SDL_Mixer:音频和声音
- SDL_Image:加载PNG等格式的图片
- SDL_Net:网络I/O
此外,韦诺之战还使用一些其他的库:
- Boost:各种高级C++特性
- Pango和Cairo:渲染各种字体
- zlib:压缩
- Python和Lua:脚本支持
- GNU gettext:国际化
在韦诺之战的引擎中,WML对象——即用字符串表示的含有子节点的字典——的使用是无处不在的。WML节点可以构造许多对象,而这些对象也可以被序列化为WML节点。引擎的一些部分将数据保存在WML字典的格式中并直接翻译它们,而不是将其解析为C++的数据结构。
韦诺之战中有若干个重要的子系统,它们大多数都尽可能地减少外部的依赖。这种高隔离度的结构也有助于提高项目的可参与性。对某个部分感兴趣的人可以工作在其特定领域的代码中,他们提交的修改不会涉及到项目的其他部分。主要子系统包括:
- WML的预处理器和解析器
- 抽象了底层库和系统调用的基础I/O模块:视频、音频和网络
- GUI模块,包括按钮、下拉列表、菜单等控件的实现
- 显示模块,渲染游戏地图、单位、动画等等
- 人工智能(AI)模块
- 寻路模块,包含了许多处理六边型游戏地图的工具函数
- 地图生成模块,用于生成各种随机地图
同时,不同的模块控制着游戏流程中的不同部分:
- titlescreen模块:控制游戏的主菜单画面的显示
- storyline模块:显示游戏中的对话、场景切换等的序列
- lobby模块:在多人游戏服务器上处理游戏的显示和创建
- "play game"模块:控制整个游戏的主模块
主模块和显示模块是韦诺之战中最大的模块。它们的功能定义是最模糊的,因为它们的功能总是在变化,很难清晰地界定。因此,这两个模块曾多次有变成“上帝对象”的危险——变成一个无法定义其行为的庞然大物。所以,它们的代码会被定期审查并将任何可以独立为模块的部分分离出来。
项目中还有一些附加功能,但它们和主项目是分开的。这包括用于多人网络游戏的多人游戏服务器,以及一个内容服务器。用户可以将他们的进度等信息上传到公共内容服务器并相互分享。两者都是用C++编写的。
25.2.韦诺标记语言
作为一个可扩展的游戏引擎,韦诺之战使用了一种简单的数据语言来保存和加载所有的游戏数据。最初我们准备使用XML,但后来大家希望使用一种对非技术用户更友好且对可视化的数据的描述更加灵活的语言。因此,我们开发了自己的数据语言,叫做“韦诺标记语言”(WML)。我们在设计时就尽量降低了它的技术难度,希望即使连学习Python和HTML都感到困难的人也能看懂WML文件。韦诺之战的所有游戏数据都存储在WML中,包括单位的定义、战役、剧情、图形界面以及其他游戏逻辑方面的配置。
WML和XML的基本性质相同,都有元素和属性,但是它不支持元素内的文本(text)。WML中的属性可以直接用一个字典式的字符串到字符串的映射表示,而程序逻辑会负责翻译这些属性。下面这个简单的例子是游戏中的“精灵战士”(Elvish Fighter)这个单位的部分定义:
[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]
由于国际化对于韦诺之战十分重要,所以WML直接支持国际化:含有下划线前缀的属性值是可翻译的。所有可翻译的字符串都会在解析时被GNU gettext
转换为适当语言的字符串。
韦诺之战选择将引擎所需的所有主要的游戏数据记录在一份WML文档(document)中,而非多份文档。这样程序就只需要一个全局变量来表示这份文档,并且在游戏加载时所有内容都会被加载,例如,所有单位的定义都可以在units
元素的所有name属性为unit_type
的子元素中找到。
尽管所有数据都是被存储在一份WML文档中的,但将它们都保存在同一个文件中则是不明智的。韦诺之战的预处理器会在解析之前遍历所有WML文件。它允许一个文件包含另一个文件甚至另一个目录的内容。例如:
{gui/default/window/}
将会包含gui/default/window/
下的所有.cfg
文件。
由于WML可能会变得非常冗长,预处理器还允许定义宏来压缩文件的长度。例如,精灵战士的定义中调用了宏{LESS_NIMBLE_ELF}
,这个宏的作用是在特定条件下使某些精灵单位不再敏捷,例如当他们在森林中静止不动的时候。
#define LESS_NIMBLE_ELF
[defense]
forest=40
[/defense]
#enddef
这个设计的优点在于游戏引擎无需了解WML文档是如何被分解到多个文件中的。如何将游戏数据分割安排到不同的文件和目录中去是WML文档的作者们的职责。
当引擎加载WML文档的时候,它会根据众多游戏设置定义一些预处理器所使用的符号。例如,韦诺之战中的战役的难度是可设置的,每个难度设置都定义了一个不同的预处理器符号。调节难度的一种常用手段是改变分配给对手的资源量(用“金”表示)。为了简化难度设置,我们定义了一个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文档所需的任何符号发生了改变的话,整个WML文档都会被重新加载并处理。例如,当用户启动游戏时,WML文档就会被加载并得到已知的战役和其他游戏内容。但是,如果用户选择了某个特定的难度——比如简单——来开始某个战役,那么整个WML文档都会根据新定义的EASY符号被重新加载。
这种设计的方便之处在于单个文档就能够包含所有的游戏数据,并且可以通过各种符号配置该WML文档。但是作为一个成功的项目,韦诺之战中的内容越来越多,其中包括许多可下载的内容——这些内容最终会被插入到这棵文档树中,这意味着这份WML文档的大小会是若干MB。它已经成为了韦诺之战的一个性能问题。在某些计算机上,加载文档就可能耗时一分钟。只要需要重新加载文档,游戏就会出现延迟。此外,它也消耗了大量的内存。我们也采取了一些应对措施,例如每个战役都会定义一个唯一的符号。因此可以用#ifdef
来定义仅有这个战役才会用到的资源,这样只有在这个战役启动的时候这些资源才会被加载。
此外,韦诺之战使用了一套缓存系统来保存,有一组定义好的键对应被处理过的WML文档。这个缓存系统会检查所有WML文件的时间戳,如果任意一个文件发生了改变,就重新生成文档。
25.3.韦诺之战中的单位
韦诺之战的主角是其中的各种单位。一名精灵战士和精灵萨满可能会遭遇一个巨魔武士和兽人苦工。所有单元都有相同的基本行为能力,但许多单位还有自己的特殊技能,能够改变正常的游戏流程。例如,巨魔单位在每一轮中都能恢复一些生命值,精灵萨满能够用纠缠根须降低敌人的移动速度,而树人在森林中是隐身的。
在引擎中表示这些的最佳方式是什么?最容易想到的方式是在C++定义一个基类unit,然后从中派生出其他类型的单位。例如,可以从unit
类中派生出一个wose_unit
类,而unit
中可以有一个返回false的虚函数bool is_invisible() const
。wose_unit
可以重载这个函数并在单位处于森林中的时候返回true。
如果游戏中的角色不多,这种方法是行得通的。但不幸的是韦诺之战相当庞大,而这种方式并不是那么容易扩展。如果在这种方式下有人希望添加一种新的单位,他就需要在游戏中添加一个新的C++类。另外,这种方式无法组合各种技能。例如,如果我们需要一种既能回血,又能减速,还能在森林中隐身的单位该怎么办?你将不得不写一个全新的类而又复制其他类之中的代码。
韦诺之战的单位系统完全没有使用继承。相反,它使用一个unit
类来表示各种单位,一个unit_type
类来表示某一类型的单位所共有的特征。unit
类会引用相应的类型对象。所有unit_type
对象都被保存在一个全局字典中,和主WML文档一同被加载。
单位的类型指的是该单位所拥有的一系列技能。例如,巨魔的“回春”的技能可以每回合都恢复一些生命值。而蜥蜴散兵的“穿插”技能使之能够穿过敌人的兵线。技能的识别是引擎的一部分——例如,寻路算法会检查一个单位是否设置了“穿插”标志来判断他是否能够自由地穿过敌人的兵线。用这种方式添加的新单位能够拥有游戏引擎实现的所有技能的任意组合,只需要在WML中定义即可。当然,不修改引擎是不可能添加全新的技能和单位的基本行为的。
此外,韦诺之战中的每个单位都可能有多种攻击方式。比如精灵弓箭手,远可以用弓,近可以用剑。每种攻击的性质和所造成的伤害都是不同的。表示攻击行为的是attack_type
类。每个unit_type
类的实例都会引用一个可用的attack_types
列表。
为了让每个单位都更有个性,韦诺之战赋予了他们“特质”。每个单位在被征招的时候都会被赋予从一个列表中随机选出的两种特质。例如,一个“力量型”的单位在近战中造成的伤害会更多,而一个“智力型”的单位“升级”所需的经验值则更少。同时,作战单位在游戏中可以拾取一些装备来变得更加强大,比如单位在捡起了一把剑之后造成的伤害会更多。为了实现特质和装备,韦诺之战的引擎可以根据WML的定义修改单位的性质。某些类型的攻击甚至也是可以修改的。例如,“力量型”特质赋予了单位更强大的近战攻击力,但是不会影响他的远程攻击力。
实现单位行为的完全WML可配置化是一个理想化的目标。因此,探究为什么韦诺之战最终没能达到这个目标也是有意义的。如果需要能够任意定义单位的行为,WML就需要变得更加灵活。它必须从一个数据描述语言扩展为一个羽翼丰满的成熟编程语言,但它也会变得令人望而却步,吓跑许多乘兴而来的贡献者。
韦诺之战的AI是用C++编写的,它了解游戏中的所有技能。它会最大限度灵活运用单位的回春、隐身等等不同技能。即使单位的技能是可以用WML创建的,也很难写出足够成熟的AI来学习并运用新的技能。实现一个AI无法利用的技能是无法令人满意的。同样,在WML中实现技能,但却需要修改AI的C++代码才能够运用这项技能也是糟糕的。因此,用WML定义单位而将技能硬编码在引擎中是最适合韦诺之战需求的折中选择。
25.4.韦诺之战的多人游戏
我们用尽可能简单的方式实现了韦诺之战的多人游戏。服务器会尽力阻止恶意的攻击,但并没有在预防作弊上下功夫。韦诺之战的一局游戏中所进行的任何动作——移动单位、攻击敌人、招募单位等等——都能被保存为一个WML节点。例如,移动一个单位的一条命令所保存得到的WML节点是这样的:
[move]
x="11,11,10,9,8,7"
y="6,7,7,8,8,9"
[/move]
它保存的是单位在玩家的命令下的行进路线。服务器在游戏中会执行接收到的任意类似的WML命令。这种设计的实用之处在于,只要保存了游戏的初始状态和随后的所有命令就等于保存了一场完整的游戏录像。重放游戏既能帮助玩家观察其他玩家的玩法,也能帮助我们调试一些错误报告。
我们希望社区的多人游戏的主旋律是友好、休闲的气氛。与其在技术上和那些整天钻研如何破解反作弊系统的反社会分子做斗争,韦诺之战基本没有反作弊。在分析了其他多人游戏之后我们认为,竞争性的排名系统是这种反社会行为的根源,而在服务器上刻意忽略这种功能则将大大降低人们的作弊动机。此外,社区的管理员也在积极地鼓励参与游戏的玩家互相建立良好的关系。这大大促进了友谊第一比赛第二的社区氛围。这些努力是非常成功的,到目前为止那些尝试恶意攻击游戏的人都被社区孤立了。
韦诺之战的多人游戏的实现是一个典型的C/S架构。服务端程序wesnothd会接受客户端的连接,并向客户端发送当前游戏的一份列表。韦诺之战会向玩家显示一个游戏大厅,玩家在其中可以选择加入他人的游戏或是创建一个新的游戏并等待他人的加入。当所有玩家都进入游戏且游戏开始之后,韦诺之战的客户端会根据玩家的行动生成WML命令。这些命令会被发送到服务器,而服务器则会将它们转发到游戏中的所有其他客户端。服务器的任务仅仅是转发而已。重放系统用于在其他客户端上执行WML命令。由于韦诺之战是一个回合制游戏,所有网络通信使用的协议都是TCP/IP。
服务器还支持观看者观看游戏。观看者可以加入一个进行中的游戏,服务器会将游戏的初始状态和游戏的命令历史全部发送给观看者。这使得新的观看者可以赶上游戏的进度。观看者可以看到游戏的进程,但不会立即到达游戏的当前状态——命令历史可以快进,但仍然需要时间。另一种方法是令某个客户端用WML描述一个游戏当前状态的快照并将它发送给新的观看者,但随着观看者的增多这种方法会增加游戏客户端的负担,而且只需让多个观看者同时加入一个游戏就可以达到“拒绝服务”攻击的效果。
当然,由于韦诺之战的客户端相互之间不会共享任何游戏状态,只会发送命令,所以游戏规则的一致性很重要。游戏服务器会根据版本将游戏分区,只有客户端版本相同的玩家才能在一起游戏。如果有人的客户端版本和其他玩家不同步,所有玩家都会立即收到警告。这也是一种有效的反作弊手段。虽然玩家想通过修改客户端来作弊并不难,但任何版本差异都会被立即通报给所有玩家并由玩家们自己来解决这个问题。
25.5.总结
我们认为韦诺之战项目的优秀之处在于所有人都能参与编码。为了实现这一目标,项目经常在代码的优雅方面作出妥协。值得一提的是虽然项目中的许多优秀程序员在见到WML的低效语法时都会皱眉头,但这种妥协正是项目的成功之源。今天,韦诺之战的最大财富就是用户创建的数百个战役和时代,而这些用户大都只有很少甚至没有任何编程经验。此外,它也激励了许多人将编程作为一种职业,而将这个项目作为一种学习工具。这种成就是许多其他项目所无法比拟的。
读者从韦诺之战项目的工作中能够学到的重要一课应该是如何去帮助那些不熟练的程序员。要认识到参与者在实际编码和磨练技能时所遇到的困难并不容易。例如,某些人可能想为项目作出贡献,但却不具备任何编程技能。类似emacs或vim这样的专业编辑器有着显著的学习曲线,只会让人们灰心丧气。因此WML的设计初衷就是任何人都能用一个简单的文本编辑器打开WML文件并作出贡献。
但是,增强代码库的可参与性并不简单。并没有什么硬性和便捷的规则能够降低代码库的门槛。相反,它需要照顾到方方面面的平衡,否则可能会对社区产生负面的后果,这在项目对依赖的处理上表现得很明显。在某些情况下,依赖会提高项目参与的门槛,但在另一些情况下也可能使参与项目更容易。具体情况需要具体分析。
我们也不应该夸大韦诺之战所取得的成功。这个项目拥有一些其他项目无法轻易复制的优点。降低编写代码的难度并吸引广泛的公众参与的成果也部分来自于项目的规则。作为一个开源项目,韦诺之战在这方面有一定的优势。在法律上,GNU许可证允许任何人打开代码文件,学习其中的原理并进行修改。这种文化鼓励所有人去实验、学习并分享,但并不一定适合于其他项目。尽管如此,我们希望这个项目中的闪光点帮助了所有开发者并让他们体会到了编程的美妙之处。