目的

通过将数据编码为虚拟机指令,给行为带来数据的灵活性。

动机

开发游戏虽然有趣,却不容易。现代游戏需求庞大且复杂的代码来实现。游戏主机制造商和应用市场的把关人员对游戏质量有着严苛的要求,一个崩溃故障就足以使你的游戏销售折戟沉沙。(译者按你在很多游戏的致谢列表中会发现QA人员占比巨大)。

我曾开发过600万行C++代码的游戏。而控制好奇号火星车的软件代码量还不足这一数字的一半。 译者按其实我觉得这种比较并不公平,虽然现在不像70年代那样“惜字节如金”,但对于外层空间勘探任务而言,存储空间还是要更多地留给探测采集数据,而控制系统则需要做到精简和耐用。此外,更多的代码意味着更多的bug,相信给火星车打补丁不会比游戏热更新容易。

同时,我们希望能够榨干平台的每一分性能。游戏极大地推动了硬件的发展,而为了能够紧跟竞争的步伐,我们必须要对游戏进行无休止的优化。

我们使用像C++这样的重量级语言来满足这些高稳定性和高性能的需求,因为它的表达足够底层,能够让大部分硬件物尽其用,而它丰富的类型系统则可以预防或至少限制bug的出现。

我们在这方面的技术让人自豪,可代价总是有的。成为大牛需要经年累月的专注训练,之后你还要对付巨量规模的代码。大型游戏的构建时间各不相同,可能只需要一杯咖啡的工夫,也有可能要等到鸡吃完了米狗舔完了面火烧段了锁才能完成。

除了上面的挑战外,游戏还必须要“有趣”才行啊,这一点需求更是刁钻。玩家想要获得平衡且新颖的游戏体验。这需要持久地迭代,但如果每个微调都要劳烦开发人员在一堆底层代码里翻弄,然后再等上巨量的重新编译的时间,才思枯竭是迟早的事。

咒术大战!

假设我们在开发一款魔法背景的战斗游戏。一对法师剑拔弩张然后互相朝着对面施法,直到决出个胜负。我们的确可以在代码里定义这些法术,但是这就意味着每当法术需要变更,开发者就得改代码。当设计师想要微调一些数据摸一摸情况的时候,他们就要重新把整个游戏编译一遍,再重启,然后再回到战斗情景。

时下的大多数游戏上市之后还需要不断地更新来修复bug或增添新内容,我们也要能够这样做。如果所有的法术都是硬编码的,那么更新就意味着要给实际的游戏可执行文件打补丁。

假设我们要更进一步,还要让游戏支持模组(modding)。我们允许用户自行创建自定义的法术。如果法术是写在源码里的,那么所有模组制作者都需要一个完整的编译器工具链来构建游戏,而且我们还必须要发布游戏的源码。更糟糕的是,假如自定义的法术存在bug,就有可能致使游戏在其他玩家的设备上崩溃。

数据大于代码

很明显我们游戏引擎的实现代码并不正好适合这项任务。我们需要一个与核心游戏隔离的沙盒环境存放法术。我们想要法术能够被轻松修改,轻松重载,而且物理上就和其他可执行文件隔绝开来。

我不知道你现在怎么想,但对我来说这就很像数据(data)。如果我们能够再单独的数据文件中定义行为,游戏引擎能够以某种方式载入并“执行”它们,我们就能够达到目标了。

我们只需要搞清楚,对于数据来说,“执行”意味着什么。你又如何用文件中的字节代表行为呢?有不少方法可以做到这一点,而我觉得如果拿解释器(Interpreter)模式作为对比,你会更容易了解本文介绍的模式的长处和短板。

解释器模式

本来么,我是可以单独开一章详细介绍这一模式的,但是有四个家伙(译者注 就是《设计模式》一书的四位作者)已经捷足先登了。所以我就直接把解释器模式的简介搬到这里来。先从语言开始讲起,也就是你想要执行的编程语言。假设该语言支持形如下方的表达式:

  1. (1 + 2) * (3 - 4)

然后,你取表达式中的每一个部分,以及编程语言中的每一条规则,将他们转换为对象(object)。字面量的数字会成为对象:
image.png
这些对象基本上就是原始值的一层包装。操作符也是一种对象,它们持有相关操作数的引用。如果你再把括号和运算优先级考虑进去,你就神奇般地得到这些对象形成的树(译者按以下是编译原理的知识,你需要正确理解文中出现的术语,如果觉得翻译欠妥或者有误请参照原文):
image.png

这是什么“魔法”?很简单——解析(parsing)。解析器会用字符串构建抽象语法树(abstract syntax tree),一个能够彰示输入文本语法结构的对象集合。

能张罗起来一颗语法树差不多就有半个编译器了。

解释器模式不在于构建这样一棵树,而是去执行它。它的工作机理很巧妙。树内的每一个对象要么是表达式,要么是子表达式。在真正面向对象的形式中,我们会让表达式计算自身的值。

首先来定义所有表达式需要实现的基本接口:

  1. class Expression{
  2. public:
  3. virtual ~Expression(){}
  4. virtual double evaluate() = 0;
  5. };

然后为每一种类型的表达式定义一个实现上述接口的类。最简单的一种就是数字表达式:

  1. class NumberExpression : public Expression
  2. {
  3. public:
  4. NumberExpression(double value)
  5. : value_(value)
  6. {}
  7. virtual double evaluate()
  8. {
  9. return value_;
  10. }
  11. private:
  12. double value_;
  13. };

字面量数字的计算结果就是它本身的值。加法表达式和乘法表达式稍微复杂一些,因为它们包含了子表达式。在计算自身之前,它们需要递归地计算出子表达式的值。如下所示:

  1. class AdditionExpression : public Expression
  2. {
  3. public:
  4. AdditionExpression(Expression* left, Expression* right)
  5. : left_(left),
  6. right_(right)
  7. {}
  8. virtual double evaluate()
  9. {
  10. // Evaluate the operands.
  11. double left = left_->evaluate();
  12. double right = right_->evaluate();
  13. // Add them.
  14. return left + right;
  15. }
  16. private:
  17. Expression* left_;
  18. Expression* right_;
  19. };

我相信你自己也能搞明白乘法表达式该怎么定义了。

挺不错的对吧?仅凭几个简单的类现在我们已经能够计算任意复杂的算数表达式了。我们只需要创建合适的对象然后正确地将其结合即可。

Ruby使用这种实现已经有差不多15年了。但在1.9版本他们换成了本章所讲的字节码实现。我可是给你省了不少事!

这个模式美妙而简洁,不过还有一些问题。在上面的图中你发现了什么?很多小盒子,盒子之间存在很多小箭头,代码是以微小对象组成的分形树来表示的,这会带来一些不良后果:

  • 从硬盘中加载它们需要大量的实例化和连接的过程。
  • 对象间的指针会消耗大量内存。在32位机器上,上面简单的算术表达式至少需要68字节的空间,这还没算上偏移值。

    如果你是在自家实验,还要把虚拟表指针的消耗算上去。

  • 遍历指针到子表达式中会巨量消耗你的数据缓存。同时,所有这些虚拟方法调用都会在指令高速缓存上造成严峻的后果。

    对于各种缓存及其影响性能的方式,参见Data Locality章节。

以上这些加起来就是一个字,“慢”。所以大部分语言不基于解释器模式是有情可原的,这一模式太慢,内存消耗也太多。

其实是机器码

回到我们的游戏例子。运行游戏时,玩家的电脑并不会在运行时去遍历一大堆C++语法树。我们提前就把它们编译成了CPU可以运行的机器码。为啥要用机器码呢?

  • 紧凑。机器码是实打实的一块连续二进制数据,没有什么被浪费的空间。
  • 线性。打包在一起的指令顺序执行,不会在内存中跳转(当然,除非你进行实际的控制流程)。
  • 底层。每条指令只做一件小事,有趣的行为来自于这些指令的组织和编排。
  • 快速。以上种种特性促成了机器码闪电般运行的速度(其实是由于它的实现是直接在硬件上的)。

这些东西听起来很好,但我们并不能真的用机器码去编写法术。而且让用户写的机器码运行在我们游戏上简直就是在安全性上自寻死路。我们需要的是在机器码和解释器之间的一种妥协。

如果我们定义自己的 虚拟机码 而不是使用实际的机器码呢?那么我们就需要在游戏内编写一个模拟器。虚拟机码和机器码很相近——紧凑,线性,相对底层——也能全盘被游戏掌控所以我们能够将其沙箱化。

这就是为什么许多游戏主机和iOS不允许程序在运行时执行生成或载入的机器码。这其实是累赘,因为最快的编程语言实现已经这么做了。它们有一个“即时(Just-In-Time)”编译器,也就是JIT,在运行中把代码翻译为优化过的机器码。

我们把这样一个模拟器称为“虚拟机(virtual machine)”,简称VM,其上运行的拟真二进制机器码被称为字节码(bytecode)。字节码兼具灵活性和轻松使用数据中定义好的内容的能力,但它比解释器模式这样的更高层级的代表有更好的性能。

在编程语言的圈子里,“虚拟机”和“解释器”是近义词,我在这里是互用这两词的。当我要引用四人组的解释器模式时,我会加上“模式”以明确表达。

不过这就有点让人望而却步了。在本章接下来的内容里,我的目标是向你展示,如果你能使功能尽量精简,这一实现其实非常容易。就算你自己最终并不会使用这一模式,你至少也能对Lua或是其他使用这一模式的语言有更深的理解。

字节码模式

指令集定义了可供执行的底层运算。一系列的指令可以被编码为字节序列虚拟机每次执行一条指令,使用一个能存储中间值的栈。通过组合指令就能定义出复杂的高级行为。

何时使用

这是本书中最复杂的一个模式,也不怎么容易在你的游戏里使用。如果你需要定义大量的行为,而游戏的实现语言又因为下述原因不适合这项任务,你可以使用此模式。

  • 语言太底层,编程很繁琐也很容易出错。
  • 由于缓慢的编译时间或者其他工具问题,游戏迭代耗时太长。
  • 需求过多的信任。如果要确保定义的行为不会搞坏游戏,你需要用沙箱将其与其余代码隔离开来。

当然,你的很多游戏都满足上面的列表。谁不想要进行更快、更安全的迭代呢?然而,天下没有免费的午餐。字节码还是要比原生代码慢,所以它并不适合于引擎中性能要求高的那部分。

牢记在心

在一个系统中创造出你自己的语言或系统可真是很诱人的选项。我在这里会展示最简单的例子,但在实际开发中这东西的体量会像藤蔓一样生长。

对于我来说,游戏开发也是一样的诱人。在各种情况下我都在创造出可供他人进行娱乐和创造的虚拟空间。

每当我看到其他人定义小型编程语言或者脚本系统的时候,他们说,“别担心,这是很轻量的。”然后就不可避免地增加一个又一个小功能直到膨胀成为一个羽翼丰满的编程语言。只是他们开发的语言不像其他语言那样,而是以一种特别的,天然的形式发展出了贫民窟的架构风范。

举例而言,目前为止所有的模板语言。 译者按说的就是你 PHP啦 🤣

做出羽翼丰满的编程语言其实不值得诟病什么。只要确保你是有意为之。否则就需要小心控制你的字节码能够控表述的范围。你要在其成为脱缰之马前先给它的嘴套上嚼子。

你需要一个前端

底层字节码指令虽有较好的性能,二进制字节码的格式却不适合给用户使用。我们把行为移除代码的一个原因就是对其在高层级进行表达。如果C++过于底层,那么让你的用户写汇编语言——即使是你自己设计出的——也不算什么提升!

Robowar这个游戏则挑战了这个断言。在该游戏中,玩家需要用一种极似汇编的语言写出小型程序来控制机器人,我们会在下面讨论指令集的种类。

这游戏使我首次接触类汇编语言。

与四人组的解释器模式相仿,此模式假设你有能够生成字节码的途径。通常情况下用户是把他们的行为用高层级的格式写出来,然后用工具将其翻译成我们虚拟机能够理解的字节码。换句话说,这个工具就是编译器。

我知道这听起来有些骇人。所以我在这里先说明一下。如果你没有资源去搭建一套制作工具,那么字节码并不适合你。但是接下来我们会发现,这并没有你想象的那么糟糕。

你需要调试器

编程不易。我们知道我们想要让机器做什么,但我们与机器的交流并不总是正确——我们会写出bug。为了寻找和修复bug,我们集聚了一堆工具去了解我们的代码究竟做错了什么,需要怎么纠正。我们有调试器,静态分析工具,反编译器等等。所有这些工具被设计为联同一些既存的语言工作:机器码或是高层级代码。

当你定义你自己的虚拟机字节码时,你会将以上工具抛到身后。你的确可以在调试期中步进到虚拟机中,但那只会告诉你虚拟机在做什么而不是它正在解释的字节码在做什么。而这对于你将字节码映射回其被编译前高层级代码也绝无帮助。

如果你正在定义的行为很简单,你无需太多工具即可进行调试。但是随着内容的增长,你就要计划对调试功能投入大量时间以帮助用户了解字节码在做什么。这些功能最终可能不会纳入到游戏中,但是没了它们你的游戏也未必能被纳入商店里。

当然,如果你想要游戏可被打上模组,你需要在游戏中加入这些功能,而这些功能也会更加重要。

示例代码

你可能觉得的上面的例子令人惊讶地直截了当。首先我们要创造出虚拟机使用的指令集。不过在考虑字节码那些东西之前,让我们先来把它当作API来看。

魔法API

如果我们直接使用C++代码定义法术,那代码需要调用什么样的API呢?法术在游戏引擎中的基本操作又是什么呢?

大部分的法术最终都会改变法师的某些数值,所以我们先从这些法术开始:

  1. void setHealth(int wizard, int amount);
  2. void setWisdom(int wizard, int amount);
  3. void setAgility(int wizard, int amount);

第一个参数表明了它会影响哪位法师,假设 0 代表玩家, 1 代表敌人。如此而来,恢复系法术可以回复玩家一方的法师血量,而法术攻击则会对玩家的死对头造成伤害。这三个简单的方法涵盖了十分广泛的魔法效果。

如果法术仅仅是对数值做出微调,那游戏逻辑不会很复杂,但是玩起来就很枯燥。来修正一下:

  1. void playSound(int soundId);
  2. void spawnParticles(int particleType);

这些方法不会影响玩法,但却提高了游戏体验。我们还可以增加视角抖动,动画等内容。不过上面这些也足够让我们起步了。

魔法指令集

现在来看看我们是如何把编程式的API转化成能用数据进行操控的内容的。我们要由浅入深地完成这一任务。现在,我们需要移除方法中的所有参数。假设 set___() 这样的方法总是影响己方法师,且总是会把相应数值回复到上限。类似地,动效操作也只会播放一种硬编码进去的声音和粒子特效。

有了以上前提,法术就只是一系列的指令了。每一个法术都指明了你要施放的操作。我们可以把它们例举出来:

  1. enum Instruction
  2. {
  3. INST_SET_HEALTH = 0x00,
  4. INST_SET_WISDOM = 0x01,
  5. INST_SET_AGILITY = 0x02,
  6. INST_PLAY_SOUND = 0x03,
  7. INST_SPAWN_PARTICLES = 0x04
  8. };

我们存储了一个枚举值数组来把法术编码为数据。我们只有少数几个不同的值,因此枚举的范围可以轻松套入到一个字节当中。这意味着法术代码只是字节序列——也就是“字节码”。
image.png

有些字节码虚拟机使用多字节来表示每条指令,并且拥有更复杂的解码规则。实际的通用芯片,比如x86上的机器码则更加复杂。

但是单个字节对于Java虚拟机微软的通用语言运行时已经足够,后者构成了.Net平台的脊骨。单字节对于我们来说也是够用的。

要想执行一条指令,我们需要检查指令是何值,然后分发至对应的API方法。

  1. switch (instruction)
  2. {
  3. case INST_SET_HEALTH:
  4. setHealth(0, 100);
  5. break;
  6. case INST_SET_WISDOM:
  7. setWisdom(0, 100);
  8. break;
  9. case INST_SET_AGILITY:
  10. setAgility(0, 100);
  11. break;
  12. case INST_PLAY_SOUND:
  13. playSound(SOUND_BANG);
  14. break;
  15. case INST_SPAWN_PARTICLES:
  16. spawnParticles(PARTICLE_FLAME);
  17. break;
  18. }

如此一来,我们的解释器就构成了代码和数据两个世界之间的桥梁。我们可以将其包装在VM类里,执行一整个法术就像下面这样:

  1. class VM {
  2. public:
  3. void interpret(char bytecode[], int size)
  4. {
  5. for (int i = 0; i < size; i++)
  6. {
  7. char instruction = bytecode[i];
  8. switch (instruction)
  9. {
  10. // Cases for each instruction...
  11. }
  12. }
  13. }
  14. };

写完这个类,你就完成了你的第一个虚拟机了。可是这类并不十分灵活。我们不能定义出影响敌人数值或降低数值的法术。我们还只能播放一种声音!

为了获得真正编程语言的表达感,我们需要增加参数。

虚拟机栈

想要执行一条复杂的嵌套表达式,你需要从最深层的子表达式开始。你先计算那些表达式,然后把结果作为参数传入上一级表达式直到执行完整个表达式。

解释器模式的将此情景建模为嵌套对象的树形结构,但我们想要的是扁平指令序列的速度。我们需要确保子表达式的结果能正确传递给包裹它的表达式。但是,由于我们的数据是扁平化的,我们必须要使用指令的顺序来控制这种行为。就像CPU所做的一样,我们使用栈来解决这个问题。

这个架构的名称是非常没有想象力的“栈式虚拟机”。Forth)、PostscriptFactor)等语言将这一模型直接暴露给了用户。

  1. class VM
  2. {
  3. public:
  4. VM()
  5. : stackSize_(0)
  6. {}
  7. // Other stuff...
  8. private:
  9. static const int MAX_STACK = 128;
  10. int stackSize_;
  11. int stack_[MAX_STACK];
  12. };

虚拟机维护了一个内部栈。在本例中,指令值的唯一形式是数字,所以我们可以使用 int 数组。只要数据需要在指令间转化时,它就需要经过这个栈。

正如其名所指,值可以入栈也可以出栈,让我们来添加一些方法:

  1. class VM
  2. {
  3. private:
  4. void push(int value)
  5. {
  6. // Check for stack overflow.
  7. assert(stackSize_ < MAX_STACK);
  8. stack_[stackSize_++] = value;
  9. }
  10. int pop()
  11. {
  12. // Make sure the stack isn't empty.
  13. assert(stackSize_ > 0);
  14. return stack_[--stackSize_];
  15. }
  16. // Other stuff...
  17. };

当一条指令需要参数时,它会将参数弹出栈:

  1. switch (instruction)
  2. {
  3. case INST_SET_HEALTH:
  4. {
  5. int amount = pop();
  6. int wizard = pop();
  7. setHealth(wizard, amount);
  8. break;
  9. }
  10. case INST_SET_WISDOM:
  11. case INST_SET_AGILITY:
  12. // Same as above...
  13. case INST_PLAY_SOUND:
  14. playSound(pop());
  15. break;
  16. case INST_SPAWN_PARTICLES:
  17. spawnParticles(pop());
  18. break;
  19. }

而为了能使栈中有值,我们需要另一条指令:字面量。它代表了一个原始整数的值。但是它从哪里得来这个整数呢?我们又是怎么避免指令在原地踏步的呢?

这个技巧利用了这样一个事实,我们的指令流是字节序列。我们可以直接将数字塞入字节数组中。我们像这样定义出一个数字字面量指令:

  1. case INSI_LITERAL:
  2. {
  3. // Read the next byte from the bytecode.
  4. int value = bytecode[++i];
  5. push(value);
  6. break;
  7. }

这里我只是读取了单字节整数,避免去调整代码来解码多字节整数。但在实际实现中,你需要使字面量支持你定义的全部数值区间。 译者按很多语言都定义了不同长度的数字,比如有符号整数可能从8位到 64位不等,但我不清楚再具体解码实现上,是每种长度分别解码,还是统一增加 padding到固定长度再进行解码,还是有其他更加聪明的方式呢?

image.png
读取字节流中的下一字节为整数并将其压入栈内。

为了理解栈是如果工作的,来试试把一序列指令放在一起然后看看解释器如何执行它们。我们先从空栈开始,解释器的指向首条指令:
image.png
首先被执行的是 INST_LITERAL 。读取下一字节 0 然后压入栈内:
image.png
然后是下一个 INST_LITERAL 。读取到了 10 然后压栈。
image.png
最后,执行 INST_HEALTH 。弹出 10 并存储在 amount 中,然后弹出 0 并存储在 wizard 中。然后,用这些参数调用 setHealth()

大功告成!我们的法术将玩家的法师生命值设为 10 点。现在我们就有了能把任何法师的生命调整到任何数值的灵活度。我们也能播放不同声音,生成不同的粒子特效。

但是…这种形式依然很像 数据 。我们不能做到这样的事,比如把某个法师的生命值增加等同于其智慧值的一半的数值。我们的设计者想要用法术表达规则,而不仅仅只是数值。

行为等于组合

如果我们把虚拟机当作一个编程语言,现在它所支持的所有功能就是一些内置的函数和常量参数。要使字节码更行为化,我们缺少的是组合(composition)。

设计师需要能创建以各种形式结合的不同值的表达式。举个简单的例子,能够增减固定数值的法术,而不是设置为固定值的法术。

这就需要把数值的当前值也考虑进去。我们已经有了写数值的指令,但是我们还要一组读数值的指令:

  1. case INST_GET_HEALTH:
  2. {
  3. int wizard = pop();
  4. push(getHealth(wizard));
  5. break;
  6. }
  7. case INST_GET_WISDOM:
  8. case INST_GET_AGILITY:
  9. // You get the idea...

正如你所见,这些指令存在着对栈的双向擦做。它们先弹出一个参数来确定需要取得哪一个法师的数值,然后找到对应的数值并把此值压入栈中。

这就使我们可以创造能复制角色数值的法术。我们可以写一个把法师的敏捷设置为法师智慧的法术,也可以写一个能把某个法师的血量匹配到敌人血量的古怪咒术。

现在比之前更进一步了,却还是有不少限制。下一步我们需要算数。我们在襁褓中的虚拟机需要学会一加一。我们再添加几条指令。现在的你应该对这些指令轻车熟路了,差不多也能猜到指令的样子。下面我仅展示加法:

  1. case INST_ADD:
  2. {
  3. int b = pop();
  4. int a = pop();
  5. push(a + b);
  6. break;
  7. }

和其他指令类似,先弹出几个数值,操作一番,然后把结果放入栈中。到目前为止,每条新增的指令都会赋予我们稍强的表达能力,但是我们已经前进了一大步。虽然并非显而易见,但我们现在已经能够处理各种复杂的嵌套算数表达式了。

让我们来看一个稍显复杂的例子。假设我们要写一个法术,该法术会使玩家的法师增加等同于该法师敏捷值和智慧的平均值的血量。写成代码就是这样:

  1. setHealth(0, getHealth(0) + (getAgility(0) + getWisdom(0)) / 2);

你可能觉得我们需要指令来处理显示的括号组合,但栈本身就非显示地支持这一点。下面是手动计算的过程:

  1. 取得法师当前血量并记忆下来。
  2. 取得法师敏捷值并记忆下来。
  3. 对法师智慧值也是同上操作。
  4. 取得后两个值并相加,记忆结果。
  5. 使上述结果除以二并记忆新的结果。
  6. 将法师血量加到该结果上。
  7. 设置法师血量为新的结果。

这里的“取得”和“记忆”代表着栈的弹出和压入。这也意味着我们能很容易地将其翻译成字节码。比如,上面列表第一行可以翻译成:

  1. LITERAL 0
  2. GET_HEALTH

这点字节码会把法师血量压入栈中。如果每条都这么翻译,我们就会得到一段能够计算原始表达式的字节码。如下所示。

为了展现栈的逐渐变化,我们用一个例子加以说明。法师当前的血量是45点,并拥有7点敏捷,11点智慧。每条指令的后面是执行该指令后栈的情况,最后附加着对此指令的注释:

  1. LITERAL 0 [0] # Wizard index
  2. LITERAL 0 [0, 0] # Wizard index
  3. GET_HEALTH [0, 45] # getHealth()
  4. LITERAL 0 [0, 45, 0] # Wizard index
  5. GET_AGILITY [0, 45, 7] # getAgility()
  6. LITERAL 0 [0, 45, 7, 0] # Wizard index
  7. GET_WISDOM [0, 45, 7, 11] # getWisdom()
  8. ADD [0, 45, 18] # Add agility and wisdom
  9. LITERAL 2 [0, 45, 18, 2] # Divisor
  10. DIVIDE [0, 45, 9] # Average agility and wisdom
  11. ADD [0, 54] # Add average to current health
  12. SET_HEALTH [] # Set health to result

一路看下来,你会发现数据魔法般地在栈中流动。我们在开头先把 0 压入栈中作为法师下标,而该值在执行完最后的 SET_HEALTH 之前都保留在栈内。

可能我使用“魔法”一词的阈值太低了。

虚拟机

我本可以接着添加指令,但现在最好先停下来。我们现在已经具备了一个精巧的虚拟机,它可以用简单且紧凑的数据格式定义相对开放的行为。虽然“虚拟机”和“字节码”在之前听起来唬人,但你会发现通常它们就像栈、循环、 switch 一样简单。

译者按原文使用了 statement描述上一段的最后一句:…as simple as a stack, a loop, and a switch statement. 但严谨来讲,语言中的 if和 switch甚至 loop也有可能是“表达式”expression,关于expression和 statement,在不同的语境下不一定可以混用,作者在这里使用 statement是因为多数情况下控制语句的确是statement,但翻译成中文则没必要强调这一点,所以这里省略不译。

还记得我们最初的目标吗?要让行为被良好地沙箱化。你已经看到了虚拟机的具体实现,此目标显然已经达成。字节码现在不能做出恶意行为,也不能触及游戏引擎的古怪部分,因为我们仅仅定义了游戏剩余部分的数条指令。

我们创建多大的栈就需要用到多大的内存,我们必须要小心溢出问题。我们甚至能够控制栈的执行此处,在指令循环中,我们可以追踪执行次数,并在逾越某些限制的时候退出循环。

在我们的例子中控制执行次数不是必要行为,因为我们并没有循环指令。我们可以通过限制字节码总大小来控制执行次数。这也意味着我们的字节码并非图灵完备的。

现在只剩下一个问题尚未解决了:真正地去创造字节码。目前为止我们都是在玩弄一些伪代码然后手工编译成字节码。除非你是闲的没事干否则那个工作量可不小。

施法工具

我们最初的目标之一是让(mod)作者的行为拥有更高级别的表达,但我们却创造了比C++还要低层级的东西。它具备我们所需要的性能和安全性,但绝对不包含设计者友好的可用性。

为了填补这一空挡,我们需要工具。我们需要有能让用户定义法术高级行为的程序,需要能从高级代码生成低级栈虚拟机字节码的的工具。

这可能听起来比做一个虚拟机还难。许多程序员也就是在大学里勉强上过几堂编译原理课,脑子里早已不剩下什么内容,一看见课本封皮上的巨龙或者“lex)”和“yacc”这样的词就会产生PTSD。

此处所指当然是那本经典教材 Compliers: Principles,Techniques,and Tools. 译者按也就是“龙书”,我当年虽然没用这本教材却也读过几页中译版。我的建议是,中文读者恰恰不要阅读这本经典教材的中译版,如果觉得难以形成思路,直接选用其他更易懂的教材。

实话说,编译基于文本的语言没那么糟糕,但在这里讨论却是有点过于扩宽主题了。不过你不一定要去实现一个编译器。我已经说过,我们只需要一个 工具 ——不一定非要是一个输入是文本文件的编译器。

恰恰相反,我鼓励你去搭建一个图形界面,让用户能够定义自己的行为,尤其是在用户的技术不强的前提下。写出没有语法错误的文本对于不熟悉编译器的用户来说是比较难的。

你则可以编写一个应用让用户以拖动和点击小盒子,打开菜单项等合理的方式来完成“写脚本”的工作。
image.png

我为 Henry Hatsworth in the Puzzling Adventure 写的脚本系统是像这样工作的。

译者按这就是为什么很多游戏引擎倾向于去做一整套工程:场景编辑,代码编辑,导入导出,打包运行等工作流程紧凑地集成在引擎对外暴露的图形界面中。而开发者每天面对最多的内容也正是这些游戏引擎的编辑器。而在这些工具之中甚至还嵌套着工具,可视化的编程流程又是更高级的一层抽象,而我觉得抽象(abstraction)正是软件开发的本质。

这一设计好处是用户不可能哦那个过这些UI创建出“非法”的程序。你可以预先使按钮失效或者提供默认值来确保用户总是能创建合法的应用,而不是直接给用户吐出一堆错误信息。

我想强调一下错误处理的重要性。作为程序员,我们倾向于把人为产生的错误视为我们极其想要铲除的可耻人性缺陷。

要制作出用户喜欢的系统,你必须要拥抱人性,包括其中的错误部分。是人总会出错,这也是创造过程的基础。能够优雅地对其进行处理的功能,比如重做(undo),能使你的用户更具创造力,也能使其创造出更好的作品。

这种做法免除了你亲自设计语法,写就解析器的步骤。但是,我明白,有些人就是觉得UI不够舒服。在这种情况下我是没有好消息带给你了。

最后,这个模式在于以用户友好且高级的形式表现行为。你必须要雕琢用户体验。想要高效地执行这些行为,你还要把它们翻译成低级别的形式。这是很庞大的工作,但如果你愿意接受挑战,最后自能苦尽甘来。

设计决策

指令如何访问栈?

需要何种指令

如何表示数值

如何生成字节码

另请参阅