第三章 房间和区域概念
原文:https://github.com/a327ex/blog/issues/17
目录
简介
在本文中,我们将介绍一些在实际开发游戏逻辑之前的一些框架结构代码。我们将探讨“房间(Room)”的概念,该概念等同于其他游戏引擎中所谓的“场景(Scene)”。然后,我们将探讨“区域(Area)”的概念,这是一个用来管理对象的类,它可以在Room
类中实例化。像之前两篇教程一样,本教程仍然没有特定于该游戏的代码(即这些代码是通用的),并将专注于更高层次的、框架结构方向的解决方案。
房间(Room)
我是从 GameMaker 文档中想到了房间这个概念。在弄清楚如何解决游戏框架设计问题时,我想做的一件事就是去看看他人是如何解决的。在这种情况下,即使我从未使用过 GameMaker,他们对房间的看法和围绕它实现的功能给了我一些非常好的启发。
就像描述的那样,房间是游戏中一切事情发生的地方(即一切行为都发生在房间里)。它们是创建、更新和绘制所有游戏对象的载体,你可以从一个房间切换到另一个房间。这些房间也只是普通的对象,我将其放置在rooms
目录下。一个名为Stage
的房间代码大致如下:
Stage = Object:extend()
function Stage:new()
end
function Stage:update(dt)
end
function Stage:draw()
end
简单房间框架设计
在最简单的形式中,这个系统只需要一个额外的变量和一个额外的函数即可工作:
function love.load()
current_room = nil
end
function love.update(dt)
if current_room then current_room:update(dt) end
end
function love.draw()
if current_room then current_room:draw() end
end
function gotoRoom(room_type, ...)
current_room = _G[room_type](...)
end
首先,在love.load
中定义了一个全局变量current_room
。这个设想是,在任何时候当前只有一个房间可以处于活跃状态,于是这个变量就保存了当前处于活跃状态的房间对象的引用。然后在love.update
和love.draw
函数中,判断如果当前有任意房间处于活跃状态,则将其更新和绘制。这同时也意味着,所有房间类都必须定义一个更新和一个绘制函数。
gotoRoom
函数可以用于切换不同的房间。它接受一个room_type
参数,它只是一个字符串,包含我们想要更改到的房间类的名称。例如,如果定义了一个名为Stage
的房间类,则意味着这里需要传Stage
字符串作为参数。这里这么做可以生效也是基于之前教程中设置的自动加载类功能,它会将所有类加载并绑定到对应的全局变量。
在 Lua 中,全局变量保存在名为_G
的全局环境表中,这意味着你可以像访问其他普通表中的元素一样访问它们。如果全局变量Stage
包含了Stage
类的定义,则可以在程序的任意位置通过直接写Stage
来访问它,也可以写成_G['Stage']
或_G.Stage
。因为我们希望能够加载任意房间,因此有必要先接受room_type
这个字符串,再通过它及全局表来访问到类定义。
最终,如果room_type
是字符串Stage
,则gotoRoom
函数内的那一行代码将变成current_room = Stage(...)
,这意味着将实例化一个新的Stage
房间。同时意味着,每当切换到一个新的房间时,都会从头开始创建一个新的房间实例,并删除前一个房间实例。它在 Lua 中的工作方式是,每当不再有任何可达的变量引用到表时,垃圾回收器最终会将其回收。这里当current_room
变量不再引用先前的房间实例时,该实例最终将被垃圾回收器回收释放。
不过这样的设计也有明显的局限性,例如,很多时候虽然你切换了一个房间,但你并不想将之间的房间删除掉,并且通常情况下你也不希望每次进入一个新房间,它都是从头开始创建。在上述设计中,没办法避免这些情况。
对于当前这个游戏来说,上述设计就是我将使用的。这个游戏一共只会有 3 ~ 4 个房间,而且这些房间之间都没有连续性,也就是说,每当我们切换一个新房间,完全可以删除旧的房间,从头创建新的房间,这样可以达到我们想要的效果。
让我们通过一个小例子来说明如何将上述系统映射到一个真实存在的游戏上。一起来看一下 Nuclear Throne:
观看此视频前 5 分钟,以了解游戏整体面貌。
游戏循环非常简单,对于上述简单房间框架设计来说,它非常合适,因为没有一个房间与前一个房间有连续性(例如,你无法返回上一张地图)。你看到的第一个屏幕画面是主菜单:
我将其创建为MainMenu
房间类,其中包含了上图中菜单所需的全部逻辑。因此它包含背景、五个选项、选择新选项时的效果,屏幕边缘的闪电等,每当玩家选择一个选项时,我都会调用gotoRoom(option_type)
切换至对应选项房间。在这个例子中,我们还将拥有Play
、CO-OP
、Settings
和Stats
这几个房间。
或者,你可以在MainMenu
房间中处理所有选项,这样你只需要一个房间。通常情况下,将所有内容放在一个房间处理,而不是通过外部系统来跳转到不同的房间是一个跟好的主意。这取决于实际情况,不过对于这个例子来说,没有足够的细节来判断哪个设计更好。
无论如何,视频中发生的下一件事是玩家选择了Play
选项,如下所示:
出现新选项,你可以在普通、日常和周常模式中进行选择。据我所知,这些不同选项仅仅只是更改关卡生成器种子,在这种情况下,我们无需再为每个选项新增一个房间(只需要在gotoRoom
调用中传递不同的种子作为参数即可)。视频中玩家选择普通模式,然后出现如下界面:
我将其称为CharacterSelect
房间,和其他房间一样,它具有显示成屏幕上效果那样所需的一切逻辑。背景、背景中的角色,选择不同角色时产生的效果以及选择角色本身应实现的逻辑。一旦选择了角色,就会出现加载界面:
接着是游戏界面:
当玩家完成当前关卡,进入下一关之前弹出的中转界面:
一旦玩家从上面的界面中选择了被动能力后,就会显示另一个加载界面。然后游戏进入下一个关卡。最后当玩家死亡时显示如下界面:
上述所有这些都是不同的界面,如果要遵循直到现在为止我遵循的逻辑,我会将它们全部设置为不同的房间:LoadingScreen
、Game
、MutationSelect
和DeathScreen
。不过,如果你仔细想想,其中有些房间就显得多余了。
例如,没有理由将LoadingScreen
从Game
中单独分离出来。正在加载的逻辑可能与关卡生成器有关,而关卡生成器又是在进入Game
房间后开始执行,因此将加载单独抽离出去是没有意义的,因为抽离出来后,加载逻辑就必须在LoadingScreen
中进行,而不是在Room
中进行,还必须得将在LoadingScreen
逻辑里创建的数据传递给Game
。我认为这是不必要的过度复杂。
另一个是死亡界面,它只是在游戏界面上覆盖了一层新的界面(游戏仍在运行),这意味着它也可能与游戏放在同一房间中。最后,我认为唯一真正可能是单独房间的是MutationSelect
。
就房间而言,Nuclear Throne 的游戏循环,就像视频中所展现的那样:MainMenu
-> Play
-> CharacterSelect
-> Game
-> MutationSelect
-> Game
-> …。如果发生死亡,你可以返回到新的MainMenu
或重试Game
。所有这些转换都可以通过简单的gotoRoom
函数来实现。
持久房间框架设计
为了更加完整,即使这个游戏不会使用这种设计,但我还是会介绍一个支持更多情况的框架设计:
function love.load()
rooms = {}
current_room = nil
end
function love.update(dt)
if current_room then current_room:update(dt) end
end
function love.draw()
if current_room then current_room:draw() end
end
function addRoom(room_type, room_name, ...)
local room = _G[room_type](room_name, ...)
rooms[room_name] = room
return room
end
function gotoRoom(room_type, room_name, ...)
if current_room and rooms[room_name] then
if current_room.deactivate then current_room:deactivate() end
current_room = rooms[room_name]
if current_room.activate then current_room:activate() end
else current_room = addRoom(room_type, room_name, ...) end
end
在这种情况下,除了提供room_type
字符串之外,现在还需要传递一个room_name
的值。因为在这种情况下,我希望能够通过某些标识符来访问对应的房间,这意味着每个room_name
必须是唯一的。这个room_name
可以是字符串,也可以是一个数字,只要它是全局唯一即可。
为了达到上述效果,新设计新增了一个addRoom
函数,该函数可以简单地实例化一个房间,并将其存储在一个表中。之后,gotoRoom
函数可以在该表中查找是否已经存在标识符对应的房间,而不是每次都实例化一个新的房间,如果存在,则直接返回该引用,否则将从头创建一个新的房间实例。
这里的另一个区别是使用activate
和deactivate
函数。只要一个房间已经存在,你又通过gotoRoom
函数再次切换到它时,首先对当前房间调用deactivate
函数,然后将当前房间的引用指向目标房间,最后调用目标房间的activate
函数。这些调用在很多情景下是非常实用的,例如将数据保存到本地或从本地加载数据,解引用变量(以便垃圾收集器可以回收他们)等等。
无论如何,新的设计允许房间是持久的,即使他们不处于活跃状态也会一直保留在内存中。因为rooms
表始终引用着他们,即使current_room
更改为另一个房间,前一个房间也不会被垃圾回收,这样未来还可以访问到它。
让我们来看一个可以很好地利用这一新设计的实例,这次是《以撒的结合》(The Binding of Isaac):
观看此视频前 5 分钟。这次,我将跳过菜单和选项,重点关注游戏玩法部分。包括从一个房间移动到另一个房间,杀死敌人并寻找物品。你可以返回之前的房间,而那些房间保留了你之前在那里发生的一切,因此,如果你杀死了敌人,并破坏了房间里的石头,那么当你之后返回时,它将没有敌人和被破坏的石头。这些行为非常适合上述系统设计。
我的实现方式是:设计一个Room
房间,所有关于游戏中房间的玩法都发生在其中。然后是一个通用的Game
房间,它在更高层级来协调各种逻辑。例如,在Game
中运用关卡生成算法生成随机地图,并且通过调用addRoom
来为地图中的每一个房间创建对应的实例。每一个实例都拥有自己唯一的 ID,当游戏开始时,通过gotoRoom
函数激活其中某一个房间作为玩家出生点。当玩家四处移动探索地牢时,将不断调用gotoRoom
函数,激活/停用不同的房间实例。
在《以撒的结合》中,当你从一个房间移动到另一个房间时,会有一个小过渡,看起来像下面这样:
我在 Nuclear Throne 例子中没有提到过这点,不过它在切换房间的时候也有一些过渡效果。实现这些效果的方式有很多种,但《以撒的结合》这样的效果,意味着需要同时绘制两个房间,因此仅使用一个current_room
变量实际上是行不通的。我不打算讨论如何修改代码来解决这个问题,但我认为值得一提的是,我这里提供的代码并不完整,经过了一些简化。一旦后面进入实际游戏逻辑实现,我将更详细地介绍这些。
房间练习
创建三个房间:
CircleRoom
,它将在屏幕中间绘制一个圆;RectangleRoom
,它将在屏幕中间绘制一个矩形;PolygonRoom
,它将在屏幕中间绘制一个多边形。通过按F1
、F2
和F3
键来切换到对应的房间。思考:在以下游戏引擎中,最接近房间的概念是什么:Unity、GODOT、HaxeFlixel、Construct 2和Phaser。浏览它们的文档并尝试找到答案。尝试查看这些引擎是如何从一个房间切换到另一个房间。
选择两个单人游戏并按照我对 Nuclear Throne 和《以撒的结合》房间划分那样为它们划分房间。试着通过现实的角度思考问题,是否有些房间可以合并成一个。并尝试准确指出何时应该执行
addRoom
和gotoRoom
。思考:一般情况下,Lua 垃圾回收器如何工作?(如果你不知道什么是垃圾回收器,请尝试搜索并了解它)Lua 中又是如何发生内存泄漏?有什么方法可以防止这些事情发生或者检测它们正在发生?
区域(Area)
下面开始介绍区域(Area)的概念。在房间内通常必须具备的逻辑是对各种对象的管理。所有对象都必须被更新和绘制,并且对象可以被添加到房间中,同时当它们死亡时还需从房间中移除。有时你还需要查询某个特定区域内的所有物体(例如,爆炸发生时,你需要对其周围一定范围内的物体造成伤害,这意味着你需要得到某个范围内所有的对象引用,并对其执行伤害逻辑),并对它们应用某些常规操作,就像根据它们的深度进行排序,以便可以按一定的顺序进行绘制等。在我制作的多个游戏的多个房间中,所有需要的功能都是相同的,因此我将它们抽象简化为一个名为Area
的类:
Area = Object:extend()
function Area:new(room)
self.room = room
self.game_objects = {}
end
function Area:update(dt)
for _, game_object in ipairs(self.game_objects) do game_object:update(dt) end
end
function Area:draw()
for _, game_object in ipairs(self.game_objects) do game_object:draw() end
end
上述设计是该Area
对象将在一个房间内被实例化。首先,上面的代码只有一个潜在游戏对象列表,并且会在update
和draw
函数中去更新和绘制它们。所有的游戏对象都将继承自GameObject
类,该类具有一些公共属性和方法,如下所示:
GameObject = Object:extend()
function GameObject:new(area, x, y, opts)
local opts = opts or {}
if opts then for k, v in pairs(opts) do self[k] = v end end
self.area = area
self.x, self.y = x, y
self.id = UUID()
self.dead = false
self.timer = Timer()
end
function GameObject:update(dt)
if self.timer then self.timer:update(dt) end
end
function GameObject:draw()
end
构造函数接受 4 个参数:area
、x
、y
位置和opts
表(包含其他可选参数)。首先要做的是访问这个可选参数表opts
,并将其所有键值对赋值给该游戏对象。例如,如果我们创建一个像game_object = GameObject(area, x, y, {a = 1, b = 2, c = 3})
这样的GameObject
,则for k, v in paris(opts) do self[k] = v
这一行代码本质上是将a = 1
,b = 2
,c = 3
声明复制到该新创建的实例身上。到此为止,你应该能理解这里所做的一切,如果不能,那么你应该再次阅读上一篇教程中关于 OOP 的部分以及 Lua 表是如何工作的相关文章。
接下来,将传入的area
引用保存在self.area
变量中,位置信息保存在self.x
和self.y
变量中。然后,为该游戏对象分配一个 ID。该 ID 对于每一个对象来说都应该是唯一的,以便我们可以区分不同的对象实例而不发生冲突。出于上述目的,在该游戏中,我们仅需简单的 UUID 生成函数。比如 lume 库中的lume.uuid
函数。不过我们不会使用该库的其他内容,而只使用这一个函数,因此仅使用这一个函数而不是整个库更加方便:
function UUID()
local fn = function(x)
local r = math.random(16) - 1
r = (x == "x") and (r + 1) or (r % 4) + 9
return ("0123456789abcdef"):sub(r, r)
end
return (("xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx"):gsub("[xy]", fn))
end
我将此代码放在名为utils.lua
的文件中。该文件将包含许多实用辅助函数,这些函数放在其他任何地方都不太合适。该函数返回的结果是一个类似于'123e4567-e89b-12d3-a456-426655440000'
的字符串,无论如何它将是唯一的。
需要注意的一点是,该函数实用了math.random
函数。如果尝试执行print(UUID())
来查看该函数生成的结果,则会发现每次运行该项目生成的内容都是相同的。造成这一现象的原因是math.random
产生随机数时使用的种子始终相同。解决这一问题的方法是,在项目运行时,根据时间设置随机数种子,像这样math.randomseed(os.time())
。
不过,我采用了另一种方法,直接使用love.math.random
而不是math.random
。如果你还记得本系列的第一篇文章,在love.run
函数中调用的第一个函数就是love.math.randomSeed(os.time())
,它与上述目的完全相同,将love.math.randoim
的种子根据时间进行设置。因为我们正在使用 LÖVE,所以,当我们需要一些随机功能的时候,可以直接 LÖVE 的随机数函数而不是 Lua 自带的功能。一旦你将UUID
函数修改,再次运行项目后,你便会看到不同的 ID。
说回游戏对象,我们为其定义了一个dead
变量,这样做的目的是当dead
变量为true
的时候,我们应将对应的游戏对象从游戏中移除。我们还为每个游戏对象实例化了一个Timer
对象。根据经验,我发现几乎所有的游戏对象都会使用计时器功能,因此这里将其作为游戏对象的默认属性是有意义的。最后,我们需要在update
函数中更新计时器。
考虑到上述这些内容,Area
类还需要做如下改动:
Area = Object:extend()
function Area:new(room)
self.room = room
self.game_objects = {}
end
function Area:update(dt)
for i = #self.game_objects, 1, -1 do
local game_object = self.game_objects[i]
game_object:update(dt)
if game_object.dead then table.remove(self.game_objects, i) end
end
end
function Area:draw()
for _, game_object in ipairs(self.game_objects) do game_object:draw() end
end
现在,update
函数还要考虑游戏对象的dead
值,并采取相应的行为。首先,游戏对象正常更新,然后检查其是否已经死亡,如果是,则需要将其从game_objects
列表中移除。这里的重点是,循环是从列表末尾开始向列表头方向进行遍历(即倒序遍历),这么做是因为,如果你在正序遍历时从中删除元素,最终会导致跳过某些元素没有被遍历到,可以参看这里的讨论。
最后,还应该为Area
添加一个addGameObject
函数,它将向区域中添加一个新的游戏对象:
function Area:addGameObject(game_object_type, x, y, opts)
local opts = opts or {}
local game_object = _G[game_object_type](self, x or 0, y or 0, opts)
table.insert(self.game_objects, game_object)
return game_object
end
它应该像这样被调用:area:addGameObject('ClassName', 0, 0, {optional_argument = 1})
。game_object_type
这个参数就像gotoRoom
函数参数那样,是一个表示游戏对象类型的字符串,即要创建的游戏对象类的名称。在上面的示例中,_G[game_object_type]
将被解析成ClassName
对应的全局变量,其包含了名为ClassName
的类定义。无论如何,该函数都将创建目标类的一个实例,并将其添加到game_objects
列表中,然后将其返回。之后,创建出的实例便可每帧被更新和绘制了。
以上介绍了这个类是如何工作的。这个类在开发游戏的过程中还将发生很大的变化,不过它仍然需要覆盖应具备的基本功能(添加、删除、更新和绘制游戏对象)。
区域练习
创建一个
Stage
房间,并在其中创建一个Area
实例。然后创建一个Circle
类,它继承自GameObject
,并且每 2 秒添加一个实例(随机位置)到Stage
房间中。Circle
实例需要在 2 ~ 4 秒之间的随机时间后销毁自己。创建一个
Stage
房间,其中不包含Area
实例。创建一个Circle
类,它不继承自GameObject
,并且每 2 秒添加一个实例(随机位置)到Stage
房间中。Circle
实例需要在 2 ~ 4 秒之间的随机时间后销毁自己。练习 1 的解题方法介绍了
random
函数,将其扩展成只需要一个参数而不是两个,并且生成一个介于 0 到给定参数之间的一个随机实数(当只接受到一个参数的情况下)。还可以继续扩展该函数,使其可以接受第一个参数大于第二个参数这样的形式。思考:
addGameObject
函数中,local opts = opts or {}
的作用?