BYTEPATH #3 - Rooms and Areas
Introduction
In this article we'll cover some structural code needed before moving on to the actual game. We'll explore the idea of Rooms, which are equivalent to what's called a scene in other engines. And then we'll explore the idea of an Area, which is an object management type of construct that can go inside a Room. Like the two previous tutorials, this one will still have no code specific to the game and will focus on higher level architectural decisions.
这一篇会先补上一些真正开始做游戏之前必须有的结构性代码。我们会先讲 Rooms 这个概念,它大致就相当于其他引擎里常说的 scene。然后再讲 Area,它是一种可以放在 Room 里面、专门负责管理对象的结构。和前两篇一样,这一篇仍然不会出现和游戏玩法强绑定的代码,重点依旧是更高层级的架构选择。
Room
I took the idea of Rooms from GameMaker's documentation. One thing I like to do when figuring out how to approach a game architecture problem is to see how other people have solved it, and in this case, even though I've never used GameMaker, their idea of a Room and the functions around it gave me some really good ideas.
Room 这个概念我是从 GameMaker 的文档 里借过来的。我自己在思考游戏架构问题时,很喜欢先看看别人是怎么解的。这次也是一样。虽然我从来没真正用过 GameMaker,但它关于 Room 的设计,还有围绕它配套的一整套函数,确实给了我不少很好的启发。
As the description there says, Rooms are where everything happens in a game. They're the places where all game objects will be created, updated and drawn and you can change from one Room to the other. Those rooms are also normal objects that I'll place inside a rooms folder. This is what one room called Stage would look like:
正如文档里说的那样,Room 就是游戏里一切实际发生的地方。所有游戏对象都在这里创建、更新和绘制,而你也可以在不同 Room 之间切换。这些 Room 本身也只是普通对象,我会把它们放在一个 rooms 文件夹里。比如,一个叫 Stage 的 Room 大致长这样:
Stage = Object:extend()
function Stage:new()
end
function Stage:update(dt)
end
function Stage:draw()
endSimple Rooms
At its simplest form this system only needs one additional variable and one additional function to work:
如果只做最朴素的版本,这个系统其实只需要一个额外变量和一个额外函数就能跑起来:
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](...)
endAt first in love.load a global current_room variable is defined. The idea is that at all times only one room can be currently active and so that variable will hold a reference to the current active room object. Then in love.update and love.draw, if there is any room currently active it will be updated and drawn. This means that all rooms must have an update and a draw function defined.
首先,在 love.load 里定义了一个全局变量 current_room。它的含义很直接:任意时刻只会有一个 Room 处于当前激活状态,所以这个变量就用来保存那一个正在活动的 Room 对象引用。然后在 love.update 和 love.draw 里,如果当前确实有激活中的 Room,就去调用它的 update 和 draw。这也就意味着,所有 Room 都必须定义这两个函数。
The gotoRoom function can be used to change between rooms. It receives a room_type, which is just a string with the name of the class of the room we want to change to. So, for instance, if there's a Stage class defined as a room, it means the 'Stage' string can be passed in. This works based on how the automatic loading of classes was set up in the previous tutorial, which loads all classes as global variables.
gotoRoom 这个函数就是用来切换 Room 的。它接收一个 room_type,本质上就是你想切到哪个 Room 类的字符串名字。比如,如果已经有一个叫 Stage 的 Room 类,那你就可以传入 'Stage'。这一切之所以成立,是因为我们在上一篇里把类自动加载成了全局变量。
In Lua, global variables are held in a global environment table called _G, so this means that they can be accessed like any other variable in a normal table. If the Stage global variable contains the definition of the Stage class, it can be accessed by just saying Stage anywhere on the program, or also by saying _G['Stage'] or _G.Stage. Because we want to be able to load any arbitrary room, it makes sense to receive the room_type string and then access the class definition via the global table.
在 Lua 里,全局变量都保存在一个叫 _G 的全局环境表里,所以它们也可以像普通表字段一样被访问。如果 Stage 这个全局变量里装的是 Stage 类定义,那么你既可以在程序里直接写 Stage,也可以写 _G['Stage'] 或者 _G.Stage。因为我们希望能够根据传进来的字符串去加载任意 Room,所以通过 _G 这张表来取对应类定义就很自然了。
So in the end, if room_type is the string 'Stage', the line inside the gotoRoom function parses to current_room = Stage(...), which means that a new Stage room is being instantiated. This also means that any time a change to a new room happens, that new room is created from zero and the previous room is deleted. The way this works in Lua is that whenever a table is not being referred to anymore by any variables, the garbage collector will eventually collect it. And so when the instance of the previous room stops being referred to by the current_room variable, eventually it will be collected.
所以,最终如果 room_type 的值是 'Stage',那么 gotoRoom 里的那一行实际就相当于 current_room = Stage(...),也就是创建了一个新的 Stage Room 实例。这同时意味着,每次切换到新 Room 时,新的 Room 都是从头创建的,而之前那个 Room 会被丢掉。在 Lua 里,只要一张表不再被任何变量引用,垃圾回收器迟早会把它回收掉。所以当前一个 Room 实例不再被 current_room 指向之后,它最终就会被收掉。
There are obvious limitations to this setup, for instance, often times you don't want rooms to be deleted when you change to a new one, and often times you don't want a new room to be created from scratch every time you change to it. Avoiding this becomes impossible with this setup.
这套方案当然有很明显的局限。比如很多时候,你并不希望切换出去之后原来的 Room 直接被销毁;同样,你也不一定希望每次切回某个 Room 时都从零重新创建。在当前这种写法下,这种需求基本无解。
For this game though, this is what I'll use. The game will only have 3 or 4 rooms, and all those rooms don't need continuity between each other, i.e. they can be created from scratch and deleted any time you move from one to the other and it works fine.
不过对这个游戏来说,这种简单方案已经够用了。因为整个游戏总共也就三四个 Room,而且它们之间并不要求那种严格的连续状态。换句话说,你完全可以在它们之间来回切的时候每次都重新创建、把旧的删掉,照样没问题。
Let's go over a small example of how we can map this system onto a real existing game. Let's look at Nuclear Throne:
我们不妨拿一个真实游戏举例,看看这套系统怎么映射进去。先看 Nuclear Throne:

Watch the first minute or so of this video until the guy dies once to get an idea of what the game is like.
你可以先看一下这个视频前一分钟左右,至少看到视频里那个人第一次死掉,这样就能对游戏流程有个直观印象。
The game loop is pretty simple and, for the purposes of this simple room setup it fits perfectly because no room needs continuity with previous rooms. (you can't go back to a previous map, for instance) The first screen you see is the main menu:
这个游戏的流程其实很简单,而且对我们这种“简化版 Room 系统”来说非常契合,因为这里的各个 Room 并不需要保留前一个 Room 的连续状态。比如你没法回到上一张地图。玩家最先看到的是主菜单:

I'd make this a MainMenu room and in it I'd have all the logic needed for this menu to work. So the background, the five options, the effect when you select a new option, the little bolts of lightning on the edges of screen, etc. And then whenever the player would select an option I would call gotoRoom(option_type), which would swap the current room to be the one created for that option. So in this case there would be additional Play, CO-OP, Settings and Stats rooms.
我会把它做成一个 MainMenu Room,并把这个菜单所需的全部逻辑都放进去。也就是背景、五个选项、切换选项时的效果、屏幕边缘那些小闪电,等等。然后每当玩家选中一个选项时,就调用 gotoRoom(option_type),把当前 Room 切到对应选项生成出来的那个。所以按这种思路,这里还会有 Play、CO-OP、Settings 和 Stats 这些额外 Room。
Alternatively, you could have one MainMenu room that takes care of all those additional options, without the need to separate it into multiple rooms. Often times it's a better idea to keep everything in the same room and handle some transitions internally rather than through the external system. It depends on the situation and in this case there's not enough details to tell which is better.
当然,你也完全可以只保留一个 MainMenu Room,把这些附加选项全都收在里面,不必拆成多个 Room。很多时候,把东西留在同一个 Room 里、自己在内部处理状态切换,反而比通过外部系统切来切去更合适。具体哪个好,还是得看场景;而在这个例子里,细节还不够多,没法武断地下结论。
Anyway, the next thing that happens in the video is that the player picks the play option, and that looks like this:
总之,视频里接下来发生的事,是玩家选中了 play 选项,然后进入了这一屏:

New options appear and you can choose between normal, daily or weekly mode. Those only change the level generation seed as far as I remember, which means that in this case we don't need new rooms for each one of those options (can just pass a different seed as argument in the gotoRoom call). The player chooses the normal option and this screen appears:
这里会出现新选项,让你在 normal、daily 和 weekly 模式之间选择。按我印象,这些模式主要只是改了关卡生成种子,所以这里没必要为每个选项都单独做一个 Room,直接在 gotoRoom 时把不同 seed 当参数传进去就够了。玩家如果选择了 normal,就会看到这屏:

I would call this the CharacterSelect room, and like the others, it would have everything needed to make that screen happen, the background, the characters in the background, the effects that happen when you move between selections, the selections themselves and all the logic needed for that to happen. Once the character is chosen the loading screen appears:
这一屏我会叫它 CharacterSelect Room。和前面一样,它会包含实现这个界面所需的一切:背景、背景里站着的角色、切换选项时的动画效果、选项本身,以及支撑这些行为的全部逻辑。等角色选完之后,就会进入加载界面:

Then the game:
然后进入游戏:

When the player finishes the current level this screen popups before the transition to the next one:
玩家打完当前关卡之后,在切到下一关之前,会先弹出这样一个界面:

Once the player selects a passive from previous screen another loading screen is shown. Then the game again in another level. And then when the player dies this one:
玩家在刚才那个界面里选完被动之后,会再来一次加载界面,然后进入下一关。最后玩家死掉时,则会出现这一屏:

All those are different screens and if I were to follow the logic I followed until now I'd make them all different rooms: LoadingScreen, Game, MutationSelect and DeathScreen. But if you think more about it some of those become redundant.
这些看上去都是不同的画面。如果完全顺着前面的思路往下推,那我可能会把它们分别做成 LoadingScreen、Game、MutationSelect 和 DeathScreen 这些独立 Room。但如果你再多想一步,就会发现其中有些其实是冗余的。
For instance, there's no reason for there to be a separate LoadingScreen room that is separate from Game. The loading that is happening probably has to do with level generation, which will likely happen inside the Game room, so it makes no sense to separate that to another room because then the loading would have to happen in the LoadingScreen room, and not on the Game room, and then the data created in the first would have to be passed to the second. This is an overcomplication that is unnecessary in my opinion.
比如,完全没必要把 LoadingScreen 做成一个和 Game 分开的独立 Room。这里的加载,八成和关卡生成有关,而关卡生成本来就应该发生在 Game Room 里。你要是硬拆出去,就变成加载逻辑得先在 LoadingScreen 里跑,然后再把生成出来的数据传给 Game,这在我看来纯属多此一举。
Another one is that the death screen is just an overlay on top of the game in the background (which is still running), which means that it probably also happens in the same room as the game. I think in the end the only one that truly could be a separate room is the MutationSelect screen.
另一个例子是死亡界面。它其实只是叠在仍然继续运行的游戏画面之上的一层覆盖物,这说明它大概率也应该发生在同一个 Game Room 里。这样想下来,真正可能值得单独拆成独立 Room 的,也许只有 MutationSelect 这个界面。
This means that, in terms of rooms, the game loop for Nuclear Throne, as explored in the video would go something like: MainMenu -> Play -> CharacterSelect -> Game -> MutationSelect -> Game -> .... Then whenever a death happens, you can either go back to a new MainMenu or retry and restart a new Game. All these transitions would be achieved through the simple gotoRoom function.
所以,从 Room 的角度去看,Nuclear Throne 在视频里的流程大概会是:MainMenu -> Play -> CharacterSelect -> Game -> MutationSelect -> Game -> …… 之后每次死亡时,要么回到新的 MainMenu,要么重开一个新的 Game。而这些切换,基本都可以通过那个简单的 gotoRoom 来完成。
Persistent Rooms
For completion's sake, even though this game will not use this setup, I'll go over one that supports some more situations:
为了把话说完整,虽然这个游戏自己不会用到,但我还是顺手讲一套能覆盖更多情况的写法:
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
endIn this case, on top of providing a room_type string, now a room_name value is also passed in. This is because in this case I want rooms to be able to be referred to by some identifier, which means that each room_name must be unique. This room_name can be either a string or a number, it really doesn't matter as long as it's unique.
在这个版本里,除了 room_type 这个类名字串之外,我们还要再传一个 room_name。因为这里的设计目标是:Room 不只是“某种类型”,它还要能被某个唯一标识符指向。所以每个 room_name 都必须唯一。这个名字可以是字符串,也可以是数字,本质上无所谓,只要唯一就行。
The way this new setup works is that now there's an addRoom function which simply instantiates a room and stores it inside a table. Then the gotoRoom function, instead of instantiating a new room every time, can now look in that table to see if a room already exists, if it does, then it just retrieves it, otherwise it creates a new one from scratch.
这套方案的运作方式是:多了一个 addRoom 函数,用来创建 Room 实例并把它存进一张表。这样一来,gotoRoom 在切换时就不用每次都新建对象了,它可以先去那张表里看看目标 Room 是否已经存在;如果存在,直接拿出来用;如果不存在,再从头创建。
Another difference here is the use of the activate and deactivate functions. Whenever a room already exists and you ask to go to it again by calling gotoRoom, first the current room is deactivated, the current room is changed to the target room, and then that target room is activated. These calls are useful for a number of things like saving data to or loading data from disk, dereferencing variables (so that they can get collected) and so on.
另一个区别是这里引入了 activate 和 deactivate。当目标 Room 已经存在,而你又通过 gotoRoom 切回它时,流程会是:先把当前 Room 停用,再把 current_room 指向目标 Room,最后激活目标 Room。这样一来,很多事情就有了明确挂载点,比如落盘存档、从磁盘读数据、释放引用让垃圾回收更顺利,等等。
In any case, what this new setup allows for is for rooms to be persistent and to remain in memory even if they aren't active. Because they're always being referenced by the rooms table, whenever current_room changes to another room, the previous one won't be garbage collected and so it can be retrieved in the future.
总之,这套新方案最大的意义就在于:Room 可以变成持久化存在的对象,就算它当前不活跃,也依然留在内存里。因为 rooms 这张表始终持有它们的引用,所以当 current_room 切到别的 Room 时,之前那个不会被垃圾回收,未来还可以直接取回来继续用。
Let's look at an example that would make good use of this new system, this time with The Binding of Isaac:
那我们再看一个更适合这种“持久 Room”系统的例子,这次换成 The Binding of Isaac:

Watch the first minute or so of this video. I'm going to skip over the menus and stuff this time and mostly focus on the actual gameplay. It consists of moving from room to room killing enemies and finding items. You can go back to previous rooms and those rooms retain what happened to them when you were there before, so if you killed the enemies and destroyed the rocks of a room, when you go back it will have no enemies and no rocks. This is a perfect fit for this system.
你可以先看看这个视频前一分钟左右。这次我就不讲菜单了,主要盯着实际玩法看。这个游戏的核心就是在一个个房间之间移动、清怪、拿道具。关键点在于:你可以回到之前的房间,而且那个房间会保留你上次进去时留下的状态。比如你把敌人打死了、石头砸碎了,那你回去时,它就还是没有敌人、没有石头。这样的结构和这套持久 Room 系统简直是天然契合。
The way I'd setup things would be to have a Room room where all the gameplay of a room happens. And then a general Game room that coordinates things at a higher level. So, for instance, inside the Game room the level generation algorithm would run and from the results of that multiple Room instances would be created with the addRoom call. Each of those instances would have their unique IDs, and when the game starts, gotoRoom would be used to activate one of those. As the player moves around and explores the dungeon further gotoRoom calls would be made and already created Room instances would be activated/deactivated as the player moves about.
如果是我来搭,大概会做一个专门的 Room Room,用来承载单个房间内部的全部玩法内容;再做一个更高层的 Game Room,负责统筹整个局面。比如说,关卡生成算法可以在 Game Room 里跑完,然后根据生成结果,用 addRoom 创建出多个 Room 实例。每个实例都有自己的唯一 ID。游戏开始时,通过 gotoRoom 激活其中一个。之后玩家在地牢里不断移动时,就继续通过 gotoRoom 在这些已经创建好的 Room 实例之间切换,当前所在的房间被激活,离开的房间则被停用。
One of the things that happens in Isaac is that as you move from one room to the other there's a small transition that looks like this:
Isaac 里还有一件事值得注意:玩家从一个房间走到另一个房间时,中间会有一个小过渡效果,大概像这样:

I didn't mention this in the Nuclear Throne example either, but that also has a few transitions that happen in between rooms. There are multiple ways to approach these transitions, but in the case of Isaac it means that two rooms need to be drawn at once, so using only one current_room variable doesn't really work. I'm not going to go over how to change the code to fix this, but I thought it'd be worth mentioning that the code I provided is not all there is to it and that I'm simplifying things a bit. Once I get into the actual game and implement transitions I'll cover this is more detail.
这个点在前面的 Nuclear Throne 例子里我也没细说,其实那边也存在 Room 之间的过渡效果。过渡的实现方式有很多种,但对 Isaac 这种情况来说,过渡时等于是要同时把两个 Room 都画出来,所以只用一个 current_room 变量其实是不够的。我这里不打算展开讲具体怎么改代码来支持这个需求,不过还是值得提前提醒一句:我现在给出的这套代码并不是 Room 系统的全部,它本身就是一个简化版。等我们在真正的游戏里做到转场效果时,我会再更细一点地讲。
Room Exercises
44. Create three rooms: CircleRoom which draws a circle at the center of the screen; RectangleRoom which draws a rectangle at the center of the screen; and PolygonRoom which draws a polygon to the center of the screen. Bind the keys F1, F2 and F3 to change to each room.
44. 创建三个 Room:CircleRoom,在屏幕中央画一个圆;RectangleRoom,在屏幕中央画一个矩形;以及 PolygonRoom,在屏幕中央画一个多边形。再把 F1、F2、F3 绑定成切换到这三个 Room 的按键。
45. What is the closest equivalent of a room in the following engines: Unity, GODOT, HaxeFlixel, Construct 2 and Phaser. Go through their documentation and try to find out. Try to also see what methods those objects have and how you can change from one room to another.
45. 在下面这些引擎里,和 Room 最接近的概念分别是什么:Unity、GODOT、HaxeFlixel、Construct 2 和 Phaser。去翻一下它们的文档,试着找出来。顺便也看看这些对象通常有哪些方法,以及它们是怎样在不同“房间”之间切换的。
46. Pick two single player games and break them down in terms of rooms like I did for Nuclear Throne and Isaac. Try to think through things realistically and really see if something should be a room on its own or not. And try to specify when exactly do addRoom or gotoRoom calls would happen.
46. 随便挑两个单机游戏,像我分析 Nuclear Throne 和 Isaac 那样,把它们拆解成一组 Room。尽量现实一点地思考,认真判断哪些界面或流程真的该独立成 Room,哪些不该。再进一步说明:在什么时机下会调用 addRoom,又会在什么时机下调用 gotoRoom。
47. In a general way, how does the garbage collector in Lua work? (and if you don't know what a garbage collector is then read up on that) How can memory leaks happen in Lua? What are some ways to prevent those from happening or detecting that they are happening?
47. 从整体上说,Lua 的垃圾回收器是怎样工作的?(如果你还不清楚垃圾回收器是什么,先去补一下这个概念。)Lua 里为什么会出现内存泄漏?又有哪些办法能防止这类问题,或者至少在它发生时把它检测出来?
Areas
Now for the idea of an Area. One of the things that usually has to happen inside a room is the management of various objects. All objects need to be updated and drawn, as well as be added to the room and removed from it when they're dead. Sometimes you also need to query for objects in a certain area (say, when an explosion happens you need to deal damage to all objects around it, this means getting all objects inside a circle and dealing damage to them), as well as applying certain common operations to them like sorting them based on their layer depth so they can be drawn in a certain order. All these functionalities have been the same across multiple rooms and multiple games I've made, so I condensed them into a class called Area:
接下来讲 Area。在一个 Room 里面,经常有一类工作是绕不过去的,那就是对象管理。所有对象都要被更新、被绘制;它们得能被添加进 Room,也得在死亡后被移除。有时候你还需要对某个区域里的对象做查询,比如爆炸发生时,要找出爆炸半径内所有对象并对它们造成伤害;或者你得对对象执行一些统一操作,比如按层级深度排序,确保绘制顺序正确。这些功能在我做过的很多 Room、很多游戏里几乎都一模一样,所以我把它们收拢成了一个叫 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
endThe idea is that this object will be instantiated inside a room. At first the code above only has a list of potential game objects, and those game objects are being updated and drawn. All game objects in the game will inherit from a single GameObject class that has a few common attributes that all objects in the game will have. That class looks like this:
它的使用方式是:在 Room 内部创建一个 Area。上面这版最基础的代码里,它暂时只保存了一张游戏对象列表,并负责让这些对象执行更新和绘制。至于游戏里的所有对象,则都会继承自同一个 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()
endThe constructor receives 4 arguments: an area, x, y position and an opts table which contains additional optional arguments. The first thing that's done is to take this additional opts table and assign all its attributes to this object. So, for instance, if we create a GameObject like this game_object = GameObject(area, x, y, {a = 1, b = 2, c = 3}), the line for k, v in pairs(opts) do self[k] = v is essentially copying the a = 1, b = 2 and c = 3 declarations to this newly created instance. By now you should be able to understand how this works, if you don't then read up more on the OOP section in the past article as well as how tables in Lua work.
构造函数接收四个参数:一个 area、一个 x, y 坐标,以及一张保存额外可选参数的 opts 表。它做的第一件事,就是把 opts 里所有字段都直接挂到对象自身上。比如,如果你这样创建对象:game_object = GameObject(area, x, y, {a = 1, b = 2, c = 3}),那么 for k, v in pairs(opts) do self[k] = v end 这一行,本质上就是把 a = 1、b = 2、c = 3 这些字段复制到新实例上。到这一步,你应该已经能看懂这件事是怎么工作的了;如果还不够顺手,就回头再复习一下前一篇的 OOP 部分,以及 Lua 里 table 的基本用法。
Next, the reference to the area instance passed in is stored in self.area, and the position in self.x, self.y. Then an ID is defined for this game object. This ID should be unique to each object so that we can identify which object is which without conflict. For the purposes of this game a simple UUID generating function will do. Such a function exists in a library called lume in lume.uuid. We're not going to use this library, only this one function, so it makes more sense to just take that one instead of installing the whole library:
接着,传进来的 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))
endI place this code in a file named utils.lua. This file will contain a bunch of utility functions that don't really fit anywhere. What this function spits out is a string like this '123e4567-e89b-12d3-a456-426655440000' that for all intents and purposes is going to be unique.
我把这段代码放在一个叫 utils.lua 的文件里。这个文件会专门收纳那些哪边都不太好归类的小工具函数。这个 UUID 函数最终会吐出类似 '123e4567-e89b-12d3-a456-426655440000' 这样的字符串,对我们现在的用途来说,已经足够当成唯一标识。
One thing to note is that this function uses the math.random function. If you try doing print(UUID()) to see what it generates, you'll find that every time you run the project it's going to generate the same IDs. This problem happens because the seed used is always the same. One way to fix this is to, as the program starts up, randomize the seed based on the time, which can be done like this math.randomseed(os.time()).
有一点需要注意:这个函数内部用的是 math.random。如果你直接 print(UUID()) 看结果,会发现每次启动项目时生成出来的 ID 序列都是一样的。原因是随机种子始终相同。解决方法之一,是在程序启动时根据时间手动重新设置种子,比如调用 math.randomseed(os.time())。
However, what I did was to just use love.math.random instead of math.random. If you remember the first article of this series, the first function called in the love.run function is love.math.randomSeed(os.time()), which does exactly the same job of randomizing the seed, but for LÖVE's random generator instead. Because I'm using LÖVE, whenever I need some random functionality I'm going to use its functions instead of Lua's as a general rule. Once you make that change in the UUID function you'll see that it starts generating different IDs.
不过我实际做的事更简单:直接把 math.random 换成 love.math.random。如果你还记得系列第一篇里讲的 love.run,那里面开头就会调用 love.math.randomSeed(os.time()),作用也是初始化随机种子,只不过是针对 LÖVE 自己的随机数生成器。因为我本来就在用 LÖVE,所以一般只要涉及随机逻辑,我都会优先用它提供的函数,而不是 Lua 自带的版本。把 UUID 函数里的随机调用改掉之后,你就会看到它开始真正生成不同的 ID 了。
Back to the game object, the dead variable is defined. The idea is that whenever dead becomes true the game object will be removed from the game. Then an instance of the Timer class is assigned to each game object as well. I've found that timing functions are used on almost every object, so it just makes sense to have it as a default for all of them. Finally, the timer is updated on the update function.
回到 GameObject 本身,接下来定义了一个 dead 变量。它的含义是:只要 dead 变成 true,这个对象就应该从游戏里被移除。除此之外,每个游戏对象还都会默认带一个 Timer 实例。因为我发现计时逻辑几乎在所有对象里都会用到,所以直接给它们都配上一个默认定时器最省事。最后,update 里也顺手把这个 timer 更新掉。
Given all this, the Area class should be changed as follows:
有了这些基础之后,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
endThe update function now takes into account the dead variable and acts accordingly. First, the game object is update normally, then a check to see if it's dead happens. If it is, then it's simply removed from the game_objects list. One important thing here is that the loop is happening backwards, from the end of the list to the start. This is because if you remove elements from a Lua table while moving forward in it it will end up skipping some elements, as this discussion shows.
现在的 update 已经把 dead 这个标记考虑进去了。它会先正常更新对象,然后检查对象是不是已经死了;如果死了,就把它从 game_objects 列表里移除。这里有一个很重要的细节:循环必须倒着跑,也就是从列表尾部一路跑到开头。因为如果你一边正向遍历 Lua 的 table,一边删除元素,就很容易跳过一些项。这篇讨论 里对这个问题讲得挺清楚。
Finally, one last thing that should be added is an addGameObject function, which will add a new game object to the Area:
最后,还需要再补一个 addGameObject 函数,用来往 Area 里添加新对象:
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
endIt would be called like this area:addGameObject('ClassName', 0, 0, {optional_argument = 1}). The game_object_type variable will work like the strings in the gotoRoom function work, meaning they're names for the class of the object to be created. _G[game_object_type], in the example above, would parse to the ClassName global variable, which would contain the definition for the ClassName class. In any case, an instance of the target class is created, added to the game_objects list and then returned. Now this instance will be updated and drawn every frame.
它的调用方式会像这样:area:addGameObject('ClassName', 0, 0, {optional_argument = 1})。这里的 game_object_type 和前面 gotoRoom 里传的字符串是同一套思路,也就是用字符串来表示要创建的类名。所以上面这个例子里的 _G[game_object_type],最终就会被解析成全局变量 ClassName,也就是 ClassName 这个类定义。总之,函数会创建目标类的实例,把它插进 game_objects 列表,再把这个实例返回出来。这样一来,这个对象之后每一帧都会被更新和绘制。
And that how this class will work for now. This class is one that will be changed a lot as the game is built but this should cover the basic behavior it should have (adding, removing, updating and drawing objects).
到这里,Area 这个类当前阶段的职责就差不多完整了。随着游戏越做越深,它还会继续被改很多次,但现在这些内容已经覆盖了它最基本该具备的行为:添加对象、移除对象、更新对象,以及绘制对象。
Area Exercises
48. Create a Stage room that has an Area in it. Then create a Circle object that inherits from GameObject and add an instance of that object to the Stage room at a random position every 2 seconds. The Circle instance should kill itself after a random amount of time between 2 and 4 seconds.
48. 创建一个带有 Area 的 Stage Room。然后再创建一个继承自 GameObject 的 Circle 对象,并让 Stage 每隔 2 秒在随机位置生成一个这样的实例。每个 Circle 实例都应该在 2 到 4 秒之间的随机时间后自我销毁。
49. Create a Stage room that has no Area in it. Create a Circle object that does not inherit from GameObject and add an instance of that object to the Stage room at a random position every 2 seconds. The Circle instance should kill itself after a random amount of time between 2 and 4 seconds.
49. 创建一个不带 Area 的 Stage Room。然后创建一个不继承 GameObject 的 Circle 对象,并让 Stage 每隔 2 秒在随机位置生成一个实例。这个 Circle 实例也应该在 2 到 4 秒之间的随机时间后自行消失。
50. The solution to exercise 1 introduced the random function. Augment that function so that it can take only one value instead of two and it should generate a random real number between 0 and the value on that case (when only one argument is received). Also augment the function so that min and max values can be reversed, meaning that the first value can be higher than the second.
50. 第 1 题的解法里引入过一个 random 函数。请扩展这个函数,让它在只传一个参数时,也能正常工作,并返回一个介于 0 和该参数之间的随机实数。另外,再扩展一下,让 min 和 max 可以反着传,也就是第一个值允许比第二个值更大。
51. What is the purpose of the local opts = opts or {} in the addGameObject function?
51. addGameObject 函数里的 local opts = opts or {} 这一句,作用到底是什么?