第1章 电子游戏与Mod开发
1.1 电子游戏的运行机制
在讲解Mod开发前,先讲讲Mod开发所特有的对象。
Minecraft是一款十分成功的电子游戏。不过,既然和市面上的电子游戏一样,Minecraft是由计算机程序组织而成的,那么它就逃不过计算机程序本身,换言之,摆脱不了CPU、内存、显示器及硬盘等的限制。和主流的电子游戏一样,Minecraft试图为玩家提供一种沉浸式的游戏体验,也就是让玩家在游戏中操控一个虚拟角色带来的体验,要尽可能和现实世界中的体验贴合。
模拟现实世界,一个无论如何都无法逃过的概念就是时间,计算机程序需要在特定的时间为玩家铺设特定的游戏场景,并为玩家设定特定的游戏目标。这听起来十分自然,但实现起来却极其困难,因为时间的概念是连续的,而计算机程序只能处理离散的数据。因此,计算机程序在模拟游戏场景时,需要将连续的时间离散化(Discretize),并为每个离散的时刻模拟游戏场景。基于这一理念,引入游戏主循环的概念。
1.1.1 游戏主循环
刻(Tick)是计算机程序模拟游戏场景的基本单位。当游戏加载并运行时,将开始模拟的场景所处的时刻称为第1刻,下一个场景称为第2刻,以此类推。假设游戏在第N刻时停止,我们把计算机程序分解为以下若干步骤。
第一步:初始化游戏
第二步:加载游戏存档
第三步:读取用户输入
第四步:模拟第1刻场景
第五步:读取用户输入
第六步:模拟第2刻场景
第七步:读取用户输入
……
倒数第三步:读取用户输入
倒数第二步:模拟第N刻场景
倒数第一步:保存游戏存档
在上面的步骤中,加粗的部分是核心的模拟过程。我们注意到其中有大段重复的步骤,可以引入一个计数器,将这些步骤合并起来。
第一步:初始化游戏
第二步:加载游戏存档
第三步:引入计数器tick,赋初值为0
第四步:将tick的值设置为旧值加1
第五步:读取用户输入
第六步:模拟第tick刻场景
第七步:tick大于或等于N吗?如果小于N则跳到第四步,如果大于或等于N则跳到下一步
第八步:保存游戏存档
可以注意到绝大多数游戏都处于第四步和第七步之间,这是一个循环,我们称之为游戏主循环(Game Loop)。几乎所有游戏,其对应的计算机程序内部都至少有一个游戏主循环的实现。
1.1.2 更新频率
保证游戏内两个相邻时刻之间的时间间距相等,对于计算机程序的实现有着极大的便利。这里举一个简单的例子:可以使用相差多少刻这样的方式,来实现事件延时功能。例如,如果希望玩家按下按钮后,约1秒后按钮回弹,而相邻两刻之间总是相差固定的50毫秒,那么可以在玩家按下按钮后,指定计算机程序在20tick后处理回弹。
相邻两刻之差的倒数,就是游戏的更新频率。在通常情况下,Minecraft这款游戏相邻两刻之间正是相差50毫秒,因此更新频率就是20Hz。这一数值在社区中对应一个更流行的概念:TPS(Ticks Per Second),Minecraft这款游戏的TPS通常为20。
考虑到相邻时刻之间的时间间距相等这一需求,需要在计算机程序中引入延时的概念,同时,还要引入一个计时器。
第一步:初始化游戏
第二步:加载游戏存档
第三步:引入计数器tick,赋初值为0
第四步:启动计时器timer
第五步:将tick的值设置为旧值加1
第六步:读取用户输入
第七步:模拟第tick刻场景
第八步:终止timer并重置,得到时间相差t毫秒
第九步:延时(50-t)毫秒
第十步:tick大于或等于N吗?如果小于N则跳到第四步,如果大于或等于N则跳到下一步
第十一步:保存游戏存档
需要注意上面的第九步。第九步基于一个假设:t比50要小,换言之,在Minecraft中,每次读取用户输入和模拟场景的过程,需要在短短的50毫秒内做完。这并不是一个很容易达到的要求,尤其在游戏中添加的Mod非常多的时候。如果该要求无法达到,则游戏主循环执行一次的时间就会超过50毫秒,游戏的TPS就会低于20,Minecraft的后台日志就会出现这样一行“臭名昭著”的文字:
Can't keep up!Did the system time change,or is the server overloaded?
因此,在设计Mod时,我们需要格外小心位于游戏主循环内执行的代码,并尽量使执行效率达到最高。只有这样,才能让玩家将我们设计的Mod和其他数十甚至数百个Mod一起使用时,仍然保证执行一次游戏主循环的时间在50毫秒内。
1.1.3 游戏状态
从游戏存档本身推知其后任何一个tick的场景是不现实的,但是从某个tick的场景推知下一个tick的场景是很容易做到的。因此,我们会为游戏设置一个状态(State),它需要做以下几件事:
● 从存档读入(记为state.load())
● 写入存档(记为state.save())
● 处理用户输入(记为state.handleInput())
● 从上一tick更新到下一tick(记为state.tick())
我们把计数器tick也整合到游戏状态中,同时将tick是否大于或等于N的比较过程内化进state.tick(),使用一个标志来标记它。这样一来,这个游戏状态还多出了以下两个对象:
● 计数器(记为state.currentTick)
● 是否接着运行游戏的标记(记为state.isRunning)
重新分解游戏的运行步骤:
第一步:初始化游戏,得到游戏状态state
第二步:加载游戏存档,也就是state.load()
第三步:启动计时器timer
第四步:将state.currentTick的值设置为旧值加1
第五步:读取用户输入,也就是state.handleInput()
第六步:模拟第tick刻场景,并更新state,也就是state.tick()
第七步:终止timer并重置,得到时间相差t毫秒
第八步:延时(50-t)毫秒
第九步:state.isRunning标记为真吗?如果为真则跳到第三步,否则跳到下一步
第十步:保存游戏存档,也就是state.save()
这其实已经非常接近Minecraft游戏的运作机制了。当然,Minecraft游戏本身的执行逻辑,还是有细小的差别的,比如第五步已经内化进了第六步,游戏也不是只有到游戏主循环结束后才保存存档。对Java非常熟悉的读者,可以通过检查net.minecraft.server.MinecraftServer类的run方法验证上面的所有步骤。
1.1.4 游戏状态的组织结构
游戏状态需能从存档读入,同时需能写入存档。这两点要求游戏状态本身应该包含能够导出成世界存档的全部信息。我们很容易想到游戏状态的内部:若干分立的维度,每个维度都有若干区块,区块里存储着方块状态及方块实体(Tile Entity)。当然,每个维度都会有大量实体,其中有一部分实体是特殊的——它们被称作玩家。对这些游戏元素来说,Java等编程语言提供了一些非常有效的方式将它们组织起来,包括但不限于类、接口、列表、集合和映射等。
本节大致介绍了Minecraft游戏运行的主要框架。接下来,本书将从Mod开发者的基本需求出发,介绍对Minecraft而言,Mod在其中扮演了什么样的角色。