BYTEPATH #5 - Game Basics
Introduction
In this part we'll start going over the game itself. First we'll go over an overview of how the game is structured in terms of gameplay, then we'll focus on a few basics that are common to all parts of the game, like its pixelated look, the camera, as well as the physics simulation. After that we'll go over basic player movement and lastly we'll take a look at garbage collection and how we should look out for possible object leaks.
这一部分开始正式进入游戏本体。我们会先看一遍游戏在玩法层面的整体结构,然后讲几个贯穿全游戏的基础内容,比如像素化的画面风格、摄像机,以及物理模拟。之后会实现玩家的基本移动,最后再看看垃圾回收,以及该怎么留意可能出现的对象泄漏问题。
Gameplay Structure
The game itself is divided in only 3 different Rooms: Stage, Console and SkillTree.
整个游戏只分成 3 个不同的 Room:Stage、Console 和 SkillTree。
The Stage room is where all the actual gameplay will take place and it will have objects such as the player, enemies, projectiles, resources, powerups and so on. The gameplay is very similar to that of Bit Blaster XL and is actually quite simple. I chose something this simple because it would allow me to focus on the other aspect of the game (the huge skill tree) more thoroughly than if the gameplay was more complicated.
Stage 房间是真正进行战斗玩法的地方,里面会有玩家、敌人、子弹、资源、强化道具之类的对象。它的玩法和 Bit Blaster XL 很像,整体上其实相当简单。我故意把玩法做得这么简单,是因为这样我就能把更多精力放在游戏的另一部分,也就是那棵巨大的技能树上;如果战斗系统本身太复杂,这一点反而顾不过来。

The Console room is where all the "menu" kind of stuff happens: changing sound and video settings, seeing achievements, choosing which ship you want to play with, accessing the skill tree, and so on. Instead of creating various different menus it makes more sense for a game that has this sort of computery look to it (also known as lazy programmer art xD) to go for this, since the console emulates a terminal and the idea is that you (the player) are just playing the game through some terminal somewhere.
Console 房间负责所有偏“菜单”的事情,比如调整音频和画面设置、查看成就、选择要驾驶的飞船、进入技能树等等。与其单独做一堆不同的菜单,不如直接顺着这种很“计算机”的视觉风格走下去,也可以说是程序员偷懒美术 xD。因为这个 console 模拟的是一个终端,整套设定就变成了:你这个玩家是在某个终端上玩这款游戏。

The SkillTree room is where all the passive skills can be acquired. In the Stage room you can get SP (skill points) that spawn randomly or after you kill enemies, and then once you die you can use those skill points to buy passive skills. The idea is to try something massive like Path of Exile's Passive Skill Tree and I think I was mildly successful at that. The skill tree I built has between 600-800 nodes and I think that's good enough.
SkillTree 房间则是用来获取各种被动技能的。在 Stage 里,你可以通过随机刷出的 SP(技能点)或者击杀敌人来获得技能点,等你死后,就能拿这些点数去购买被动技能。我想做的是一种类似 Path of Exile 被动技能树 那样体量很大的系统,最后效果我觉得算是基本做出来了。我做的这棵技能树大概有 600 到 800 个节点,我觉得已经够用了。

I'll go over the creation of each of those rooms in detail, including all skills in the skill tree. However, I highly encourage you to deviate from what I'm writing as much as possible. A lot of the decisions I'm making when it comes to gameplay are pretty much just my own preference, and you might prefer something different.
后面我会把这几个房间的实现过程都详细讲一遍,技能树里的所有技能也都会讲到。不过我非常建议你尽量多偏离我的写法。这里很多和玩法相关的决定,本质上都只是我自己的偏好,你很可能会更喜欢另一套方案。
For instance, instead of a huge skill tree you could prefer a huge class system that allows tons of combinations like Tree of Savior's. So instead of building the passive skill tree like I am, you could follow along on the implementation of all passive skills, but then build your own class system that uses those passive skills instead of building a skill tree.
比如说,你也许会更喜欢做一套庞大的职业系统,而不是一棵巨大的技能树,像 Tree of Savior 那样能组合出很多流派。所以你完全可以照着本文把各种被动技能先做出来,但最后不是把它们装进技能树,而是自己再设计一套职业系统,让这些被动技能成为职业系统的一部分。
This is just one idea and there are many different areas in which you could deviate in a similar way. One of the reasons I'm writing these tutorials with exercises is to encourage people to engage with the material by themselves instead of just following along because I think that that way people learn better. So whenever you see an opportunity to do something differently I highly recommend trying to do it.
这只是其中一个例子。类似这样可以改写、可以走岔路的地方其实还有很多。我之所以在教程里安排练习题,就是想鼓励大家自己和内容较劲,而不是只跟着照抄,因为我觉得人只有这样学得才更扎实。所以,只要你看到有地方可以换一种做法,我都很建议你亲手试一试。
Game Size
Now let's start with the Stage. The first thing we want (and this will be true for all rooms, not just the Stage) is for it to have a sort low resolution pixelated look to it. For instance, look at this circle:
现在从 Stage 开始。首先我们想要的,是一种低分辨率、带像素颗粒感的视觉效果,而且这不只是 Stage,而是所有房间都会共享的要求。比如先看这个圆:

And then look at this:
再看这个:

I want the second one. The reason for this is purely aesthetic and my own personal preference. There are a number of games that don't go for the pixelated look but still use simple shapes and colors to get a really nice look, like this one. So it just depends on which style you prefer and how much you can polish it. But for this game I'll go with the pixelated look.
我想要的是第二种。原因纯粹只是审美和个人偏好。当然,也有不少游戏并不追求像素感,但照样能用简单的形状和颜色做出很好看的画面,比如这个。所以最终还是看你更喜欢哪种风格,以及你能把它打磨到什么程度。不过这个项目里,我会选择像素风这一边。
The way to achieve that is by defining a very small default resolution first, preferably something that scales up exactly to a target resolution of 1920x1080. For this game I'll go with 480x270, since that's the target 1920x1080 divided by 4. To set the game's size to be this by default we need to use the file conf.lua, which as I explained in a previous article is a configuration file that defines a bunch of default settings about a LÖVE project, including the resolution that the window will start with.
要实现这种效果,最简单的办法就是先定义一个非常小的基础分辨率,最好它能整倍数放大到目标分辨率 1920x1080。这个项目里我会用 480x270,因为它刚好是 1920x1080 的四分之一。想把游戏默认尺寸设成这个值,就得用到 conf.lua。前面已经提过,它是一个用来配置 LÖVE 项目默认设置的文件,其中也包括窗口启动时的分辨率。
On top of that, in that file I also define two global variables gw and gh, corresponding to width and height of the base resolution, and sx and sy ones, corresponding to the scale that should be applied to the base resolution. The conf.lua file should be placed in the same folder as the main.lua file and this is what it should look like:
除此之外,我还会在这个文件里定义两个全局变量 gw 和 gh,分别表示基础分辨率的宽和高;再定义 sx 和 sy,表示这个基础分辨率应当被放大的倍数。conf.lua 应该和 main.lua 放在同一个目录下,内容大概如下:
gw = 480
gh = 270
sx = 1
sy = 1
function love.conf(t)
t.identity = nil -- The name of the save directory (string)
t.version = "0.10.2" -- The LÖVE version this game was made for (string)
t.console = false -- Attach a console (boolean, Windows only)
t.window.title = "BYTEPATH" -- The window title (string)
t.window.icon = nil -- Filepath to an image to use as the window's icon (string)
t.window.width = gw -- The window width (number)
t.window.height = gh -- The window height (number)
t.window.borderless = false -- Remove all border visuals from the window (boolean)
t.window.resizable = true -- Let the window be user-resizable (boolean)
t.window.minwidth = 1 -- Minimum window width if the window is resizable (number)
t.window.minheight = 1 -- Minimum window height if the window is resizable (number)
t.window.fullscreen = false -- Enable fullscreen (boolean)
t.window.fullscreentype = "exclusive" -- Standard fullscreen or desktop fullscreen mode (string)
t.window.vsync = true -- Enable vertical sync (boolean)
t.window.fsaa = 0 -- The number of samples to use with multi-sampled antialiasing (number)
t.window.display = 1 -- Index of the monitor to show the window in (number)
t.window.highdpi = false -- Enable high-dpi mode for the window on a Retina display (boolean)
t.window.srgb = false -- Enable sRGB gamma correction when drawing to the screen (boolean)
t.window.x = nil -- The x-coordinate of the window's position in the specified display (number)
t.window.y = nil -- The y-coordinate of the window's position in the specified display (number)
t.modules.audio = true -- Enable the audio module (boolean)
t.modules.event = true -- Enable the event module (boolean)
t.modules.graphics = true -- Enable the graphics module (boolean)
t.modules.image = true -- Enable the image module (boolean)
t.modules.joystick = true -- Enable the joystick module (boolean)
t.modules.keyboard = true -- Enable the keyboard module (boolean)
t.modules.math = true -- Enable the math module (boolean)
t.modules.mouse = true -- Enable the mouse module (boolean)
t.modules.physics = true -- Enable the physics module (boolean)
t.modules.sound = true -- Enable the sound module (boolean)
t.modules.system = true -- Enable the system module (boolean)
t.modules.timer = true -- Enable the timer module (boolean), Disabling it will result 0 delta time in love.update
t.modules.window = true -- Enable the window module (boolean)
t.modules.thread = true -- Enable the thread module (boolean)
endIf you run the game now you should see a smaller window than you had before.
现在运行游戏的话,你应该会看到一个比之前更小的窗口。
Now, to achieve the pixelated look when we scale the window up we need to do some extra work. If you were to draw a circle at the center of the screen (gw/2, gh/2) right now, like this:
接下来,为了在窗口放大后依然保持像素风,我们还得多做一点处理。假设你现在直接在屏幕中心,也就是 gw/2, gh/2 位置画一个圆,效果像这样:

And scale the screen up directly by calling love.window.setMode with width 3*gw and height 3*gh, for instance, you'd get something like this:
然后直接调用 love.window.setMode,把窗口尺寸改成 3*gw 和 3*gh,结果大概会是这样:

And as you can see, the circle didn't scale up with the screen and it just stayed a small circle. And it also didn't stay centered on the screen, because gw/2 and gh/2 isn't the center of the screen anymore when it's scaled up by 3. What we want is to be able to draw a small circle at the base resolution of 480x270, but then when the screen is scaled up to fit a normal monitor, the circle is also scaled up proportionally (and in a pixelated manner) and its position also remains proportionally the same. The easiest way to do that is by using a Canvas, which also goes by the name of framebuffer or render target in other engines. First, we'll create a canvas with the base resolution in the constructor of the Stage class:
可以看到,圆并没有跟着屏幕一起放大,它还是原来那个小圆。而且它也不再处在屏幕正中央,因为当窗口放大 3 倍之后,gw/2 和 gh/2 已经不再是新的屏幕中心了。我们真正想要的是:先在 480x270 这个基础分辨率里画一个小圆,然后等整个画面放大到普通显示器尺寸时,这个圆也会按比例一起放大,而且是保留像素感的放大,同时它的位置也始终保持对应的比例。要做到这一点,最简单的办法就是用 Canvas。在别的引擎里,它通常也叫 framebuffer 或 render target。第一步,我们先在 Stage 类的构造函数里创建一个基础分辨率大小的 canvas:
function Stage:new()
self.area = Area(self)
self.main_canvas = love.graphics.newCanvas(gw, gh)
endThis creates a canvas with size 480x270 that we can draw to:
这样就创建出了一个大小为 480x270 的 canvas,之后我们就可以往上面画东西:
function Stage:draw()
love.graphics.setCanvas(self.main_canvas)
love.graphics.clear()
love.graphics.circle('line', gw/2, gh/2, 50)
self.area:draw()
love.graphics.setCanvas()
endThe way the canvas is being drawn to is simply following the example on the Canvas page. According to the page, when we want to draw something to a canvas we need to call love.graphics.setCanvas, which will redirect all drawing operations to the currently set canvas. Then, we call love.graphics.clear, which will clear the contents of this canvas on this frame, since it was also drawn to in the last frame and every frame we want to draw everything from scratch. Then after that we draw what we want to draw and use setCanvas again, but passing nothing this time, so that our target canvas is unset and drawing operations aren't redirected to it anymore.
这里的写法基本就是照着 Canvas 页面里的例子来的。按照文档,要把内容画到某个 canvas 上,首先得调用 love.graphics.setCanvas,这样之后的绘图操作就都会被重定向到当前 canvas。然后调用 love.graphics.clear,把这个 canvas 在当前帧里的内容清空。因为上一帧也往上面画过,而我们每一帧都希望从头重画。清完之后,画上你真正想画的内容,最后再调用一次 setCanvas,但这次不传参数,把当前目标 canvas 取消掉,让后续绘图不再继续画到它身上。
If we stopped here then nothing would appear on the screen. This happens because everything we drew went to the canvas but we're not actually drawing the canvas itself. So now we need to draw that canvas itself to the screen, and that looks like this:
如果到这里就停下,那屏幕上其实什么也看不到。因为所有东西只是被画进了 canvas,但我们还没有把这个 canvas 本身画到屏幕上。所以接下来得把 canvas 再绘制到窗口里,代码如下:
function Stage:draw()
love.graphics.setCanvas(self.main_canvas)
love.graphics.clear()
love.graphics.circle('line', gw/2, gh/2, 50)
self.area:draw()
love.graphics.setCanvas()
love.graphics.setColor(255, 255, 255, 255)
love.graphics.setBlendMode('alpha', 'premultiplied')
love.graphics.draw(self.main_canvas, 0, 0, 0, sx, sy)
love.graphics.setBlendMode('alpha')
endWe simply use love.graphics.draw to draw the canvas to the screen, and then we also wrap that with some love.graphics.setBlendMode calls that according to the Canvas page on the LÖVE wiki are used to prevent improper blending. If you run this now you should see the circle being drawn.
这里其实就是用 love.graphics.draw 把 canvas 画到屏幕上。另外,我们还用 love.graphics.setBlendMode 把这次绘制包了一下,LÖVE wiki 的 Canvas 页面里提到,这样可以避免混合模式出问题。现在运行的话,你就应该能看见那个圆了。
Note that we used sx and sy to scale the Canvas up. Those variables are set to 1 right now, but if you change those variables to 3, for instance, this is what would happen:
注意,这里是用 sx 和 sy 来放大 canvas 的。它们现在还都是 1,但如果你把它们改成 3,结果会变成这样:

You can't see anything! But this is the because the circle that was now in the middle of the 480x270 canvas, is now in the middle of a 1440x810 canvas. Since the screen itself is only 480x270, you can't see the entire Canvas that is bigger than the screen. To fix this we can create a function named resize in main.lua that will change both sx and sy as well as the screen size itself whenever it's called:
你什么都看不见了。原因是,原本位于 480x270 canvas 正中央的那个圆,现在其实跑到了一个 1440x810 的 canvas 正中央。而你的屏幕窗口本身还是 480x270,当然看不到这个更大 canvas 的完整内容。解决办法是在 main.lua 里写一个 resize 函数,每次调用时同时修改 sx、sy,以及窗口本身的尺寸:
function resize(s)
love.window.setMode(s*gw, s*gh)
sx, sy = s, s
endAnd so if we call resize(3) in love.load, this should happen:
这样一来,如果我们在 love.load 里调用 resize(3),结果就会变成这样:

And this is roughly what we wanted. There's only one problem though: the circle looks kinda blurry instead of being properly pixelated.
这就差不多接近我们想要的效果了。不过还有一个问题:这个圆看起来有点糊,不够像真正的像素放大。
The reason for this is that whenever things are scaled up or down in LÖVE, they use a FilterMode and this filter mode is set to 'linear' by default. Since we want the game to have a pixelated look we should change this to 'nearest'. Calling love.graphics.setDefaultFilter with the 'nearest' argument at the start of love.load should fix the issue. Another thing to do is to set the LineStyle to 'rough'. Because it's set to 'smooth' by default, LÖVE primitives will be drawn with some aliasing to them, and this doesn't work for a pixelated look. If you do all that and run the code again, it should look like this:
原因在于,LÖVE 在缩放图像时会使用一种 FilterMode,而它默认是 'linear'。既然我们想要的是像素风,就应该把它改成 'nearest'。在 love.load 开头调用 love.graphics.setDefaultFilter,并传入 'nearest',就能解决这个问题。还有一件事也要做,就是把 LineStyle 设成 'rough'。因为它默认是 'smooth',LÖVE 在绘制基础图元时会带一点抗锯齿,这和像素感是不搭的。把这些都做完后,再运行代码,效果应该像这样:

And it looks crispy and pixelated like we wanted it to! Most importantly, now we can use one resolution to build the entire game around. If we want to spawn an object at the center of the screen then we can say that it's x, y position should be gw/2, gh/2, and no matter what the resolution that we need to serve, that object will always be at the center of the screen. This significantly simplifies the process and it means we only have to worry about how the game looks and how things are distributed around the screen once.
这样看起来就够干脆、够像素了,正是我们想要的效果。更重要的是,现在我们终于可以围绕同一套基础分辨率来搭整个游戏。比如我们想让某个对象出生在屏幕正中央,只要把它的 x, y 设成 gw/2, gh/2 就行。不管最后游戏运行在什么分辨率下,这个对象都会始终出现在屏幕中央。这样一来,开发过程会简单很多,因为画面布局和视觉分布你只需要考虑一遍。
Game Size Exercises
65. Take a look at Steam's Hardware Survey in the primary resolution section. The most popular resolution, used by almost half the users on Steam is 1920x1080. This game's base resolution neatly multiplies to that. But the second most popular resolution is 1366x768. 480x270 does not multiply into that at all. What are some options available for dealing with odd resolutions once the game is fullscreened into the player's monitor?
65. 去看看 Steam Hardware Survey 里的主显示器分辨率统计。目前最常见的分辨率是 1920x1080,差不多有将近一半 Steam 用户在用,而本游戏的基础分辨率刚好能整倍数放大到它。但第二常见的是 1366x768,480x270 根本没法整倍数适配这个分辨率。那当游戏全屏到玩家的显示器上时,面对这种“奇怪分辨率”,有哪些可行的处理方案?
66. Pick a game you own that uses the same or a similar technique to what we're doing here (scaling a small base resolution up). Usually games that use pixel art will do that. What is that game's base resolution? How does the game deal with odd resolutions that don't fit neatly into its base resolution? Change the resolution of your desktop and run the game various times with different resolutions to see what changes and how it handles the variance.
66. 找一款你自己拥有的游戏,它使用了和这里类似的方案,也就是先用一个较小的基础分辨率,再整体放大。一般像素游戏常会这样做。它的基础分辨率是多少?当遇到不能整齐适配基础分辨率的屏幕时,它是怎么处理的?试着修改桌面分辨率,用不同分辨率反复运行这款游戏,观察它到底改了什么,以及它是怎样应对这些变化的。
Camera
All three rooms will make use of a camera so it makes sense to go through it now. From the second article in this series we used a library named hump for timers. This library also has a useful camera module that we'll also use. However, I use a slightly modified version of it that also has screen shake functionality. You can download the files here. Place the camera.lua file directory of the hump library (and overwrite the already existing camera.lua) and then require the camera module in main.lua. And place the Shake.lua file in the objects folder.
这三个房间都会用到摄像机,所以现在先把它讲掉比较合适。前面第二篇里我们已经用过 hump 这个库来处理计时。这个库本身也带了一个很实用的 camera 模块,我们接下来也会用它。不过我这里用的是一个稍微改过的版本,多加了屏幕震动功能。你可以从这里下载相关文件。把 camera.lua 放到 hump 库目录里,覆盖原来那个 camera.lua,然后在 main.lua 里 require 这个 camera 模块。至于 Shake.lua,则放进 objects 文件夹。
(Additionally, you can also use this library I wrote which has all this functionality already. I wrote this library after I wrote the entire tutorial, so the tutorial will go on as if the library didn't exist. If you do choose to use this library then you can follow along on the tutorial but sort of translating things to use the functions in this library instead.)
(另外,你也可以直接使用我后来写的这个库,里面已经把这些功能都打包好了。这个库是在整套教程写完之后我才做的,所以教程正文会默认它不存在。如果你决定用这个库,也完全可以继续跟着教程走,只是需要在脑子里把文中的实现方式对照成这个库里的对应函数。)
One function you'll need after adding the camera is this:
把 camera 加进来之后,你还需要补上这样一个函数:
function random(min, max)
local min, max = min or 0, max or 1
return (min > max and (love.math.random()*(min - max) + max)) or (love.math.random()*(max - min) + min)
endThis function will allow you to get a random number between any two numbers. It's necessary because the Shake.lua file uses it. After defining that function in utils.lua try something like this:
这个函数可以让你在任意两个数之间取随机值。之所以需要它,是因为 Shake.lua 里会用到。把它定义到 utils.lua 之后,可以试一下下面这段:
function love.load()
...
camera = Camera()
input:bind('f3', function() camera:shake(4, 60, 1) end)
...
end
function love.update(dt)
...
camera:update(dt)
...
endAnd then on the Stage class:
然后在 Stage 类里这样写:
function Stage:draw()
love.graphics.setCanvas(self.main_canvas)
love.graphics.clear()
camera:attach(0, 0, gw, gh)
love.graphics.circle('line', gw/2, gh/2, 50)
self.area:draw()
camera:detach()
love.graphics.setCanvas()
love.graphics.setColor(255, 255, 255, 255)
love.graphics.setBlendMode('alpha', 'premultiplied')
love.graphics.draw(self.main_canvas, 0, 0, 0, sx, sy)
love.graphics.setBlendMode('alpha')
endAnd you'll see that the screen shakes like this when you press f3:
这样一来,按下 f3 时屏幕就会像这样抖动:

The shake function is based on the one described on this article and it takes in an amplitude (in pixels), a frequency and a duration. The screen will shake with a decay, starting from the amplitude, for duration seconds with a certain frequency. Higher frequencies means that the screen will oscillate more violently between extremes (amplitude, -amplitude), while lower frequencies will do the contrary.
这个 shake 函数是基于这篇文章里的思路做的。它接收振幅(像素单位)、频率和持续时间三个参数。屏幕会从给定的振幅开始,带着衰减效果,在指定持续时间内按某个频率震动。频率越高,屏幕在正负极值之间摆动得越猛烈;频率越低,抖动就会相对平缓一些。
Another important thing to notice about the camera is that it's not anchored to a certain spot right now, and so when it shakes it will be thrown in all directions, making it be positioned elsewhere by the time the shaking ends, as you could see in the previous gif.
另外还要注意一件事:现在 camera 并没有固定在某个位置上,所以一旦它开始震动,就会在各个方向上被甩来甩去,等震动结束时,它有可能已经停在了别的地方。前面的 gif 里你应该已经看出来了。
One way to fix this is to center it and this can be achieved with the camera:lockPosition function. In the modified version of the camera module I changed all camera movement functions to take in a dt argument first. And so that would look like this:
一个解决办法,就是把 camera 锁定在屏幕中心,这可以通过 camera:lockPosition 来实现。在我改过的 camera 模块里,所有和 camera 移动相关的函数都被我改成了先接收一个 dt 参数。所以这里会写成:
function Stage:update(dt)
camera.smoother = Camera.smooth.damped(5)
camera:lockPosition(dt, gw/2, gh/2)
self.area:update(dt)
endThe camera smoother is set to damped with a value of 5. This was reached through trial and error but basically it makes the camera focus on the target point in a smooth and nice way. And the reason I placed this code inside the Stage room is that right now we're working with the Stage room and that room happens to be the one where the camera will need to be centered in the middle and never really move (other than screen shakes). And so that results in this:
这里把 camera 的 smoother 设成了 damped(5)。这个数值基本是我一点点试出来的,效果就是让 camera 平滑地追上目标点,看起来顺眼一些。至于为什么把这段代码写在 Stage 房间里,是因为我们现在处理的正好就是 Stage,而这个房间里的 camera 基本只需要待在正中央不动,除了屏幕震动之外不会真的移动。所以最后效果会变成这样:

We will use a single global camera for the entire game since there's no real need to instantiate a separate camera for each room. The Stage room will not use the camera in any way other than screen shakes, so that's where I'll stop for now. Both the Console and SkillTree rooms will use the camera more extensively but we'll get to that when we get to it.
整个游戏里我们只会用一个全局 camera,因为完全没必要给每个房间都单独实例化一个。Stage 这边对 camera 的使用,除了屏幕震动之外暂时没有别的,所以先讲到这里。等讲到 Console 和 SkillTree 时,camera 会用得更多,到时候再展开。
Player Physics
Now we have everything needed to start with the actual game and we'll start with the Player object. Create a new file in the objects folder named Player.lua that looks like this:
到这里,我们已经把正式进入游戏所需的基础准备好了,接下来从 Player 对象开始。先在 objects 文件夹里新建一个 Player.lua 文件,内容如下:
Player = GameObject:extend()
function Player:new(area, x, y, opts)
Player.super.new(self, area, x, y, opts)
end
function Player:update(dt)
Player.super.update(self, dt)
end
function Player:draw()
endThis is the default way a new game object class in the game should be created. All of them will inherit from GameObject and will have the same structure to its constructor, update and draw functions. Now we can instantiate this Player object in the Stage room like this:
这就是这个项目里新建游戏对象类的标准写法。所有对象都会继承自 GameObject,构造函数、update 和 draw 的结构也都保持一致。现在我们可以在 Stage 房间里把 Player 实例化出来:
function Stage:new()
...
self.area:addGameObject('Player', gw/2, gh/2)
endTo test that the instantiation worked and that the Player object is being updated and drawn by the Area, we can simply have it draw a circle in its position:
为了确认实例化已经生效,而且 Player 的确被 Area 正常更新和绘制,我们可以先让它在自己的位置画一个圆:
function Player:draw()
love.graphics.circle('line', self.x, self.y, 25)
endAnd that should give you a circle at the center of the screen. It's interesting to note that the addGameObject call returns the created object, so we could keep a reference to the player inside Stage's self.player, and then if we wanted we could trigger the Player object's death with a keybind:
这样你就应该能在屏幕中央看到一个圆。顺便一提,addGameObject 会把创建出来的对象返回出来,所以我们也可以把它存到 Stage 的 self.player 里。这样的话,如果我们愿意,还可以通过按键来手动触发 Player 的死亡:
function Stage:new()
...
self.player = self.area:addGameObject('Player', gw/2, gh/2)
input:bind('f3', function() self.player.dead = true end)
endAnd if you press the f3 key then the Player object should be killed, which means that the circle will stop being drawn. This happens as a result of how we set up our Area object code from the previous article. It's also important to note that if you decide to hold references returned by addGameObject like this, if you don't set the variable holding the reference to nil that object will never be collected. And so it's important to keep in mind to always nil references (in this case by saying self.player = nil) if you want an object to truly be removed from memory (on top of settings its dead attribute to true).
按下 f3 之后,Player 对象就会被“杀掉”,也就是这个圆不会再被绘制出来了。这能成立,是因为前一篇里我们已经把 Area 的对象管理逻辑搭好了。另外还有一点很重要:如果你像这样把 addGameObject 返回的对象额外存了一份引用,那么只要这个引用变量没有被设成 nil,这个对象就永远不会被垃圾回收。所以如果你真想把一个对象从内存里彻底清掉,除了把它的 dead 属性设为 true 之外,也要记得把你手里保存它的引用一并设成 nil,比如这里的 self.player = nil。
Now for the physics. The Player (as well as enemies, projectiles and various resources) will be a physics objects. I'll use LÖVE's box2d integration for this, but this is something that is genuinely not necessary for this game, since it benefits in no way from using a full physics engine like box2d. The reason I'm using it is because I'm used to it. But I highly recommend you to try either rolling you own collision routines (which for a game like this is very easy to do), or using a library that handles that for you.
下面开始做物理部分。Player,以及敌人、子弹、各种资源对象,都会作为物理对象来处理。这里我会用 LÖVE 对 box2d 的集成,但说实话,这个游戏并不真的需要像 box2d 这样完整的物理引擎,它并不会从中获得什么本质上的收益。我之所以这么做,只是因为我自己用习惯了。不过我其实更推荐你自己写一套碰撞逻辑,像这种游戏做起来并不难;或者你也可以用别的专门处理碰撞的库。
What I'll use and what the tutorial will follow is a library called windfield that I created which makes using box2d with LÖVE a lot easier than it would otherwise be. Other libraries that also handle collisions in LÖVE are HardonCollider or bump.lua.
我这里会使用,也就是教程接下来会跟着走的,是我自己写的一个库 windfield。它能把 LÖVE 里 box2d 的使用变得轻松很多。LÖVE 里另外两个常见的碰撞库还有 HardonCollider 和 bump.lua。
I highly recommend for you to either do collisions on your own or use one of these two other libraries instead of the one the tutorial will follow. This is because this will make you exercise a bunch of abilities that you'll constantly have to exercise, like picking between various distinct solutions and seeing which one fits your needs and the way you think best, as well as coming up with your own solutions to problems that will likely arise instead of just following a tutorial.
我非常建议你要么自己做碰撞,要么用刚才提到的另外两个库,而不是完全照着教程去用 windfield。因为这样你才能练到那些真正会反复用到的能力,比如在多种方案之间做判断,看哪种更适合你的需求和思路;再比如遇到问题时,自己想办法解决,而不是只靠照着教程走。
To repeat this again, one of the main reasons why the tutorial has exercises is so that people actively engage with the material so that they actually learn, and this is another opportunity to do that. If you just follow the tutorial along and don't learn to confront things you don't know by yourself then you'll never truly learn. So I seriously recommend deviating from the tutorial here and doing the physics/collision part of the game on your own.
我再强调一次,这套教程之所以有练习题,一个很大的原因就是希望你能主动和材料发生碰撞,而不是只复制答案。现在这里就是又一个很好的机会。如果你只是一路跟着抄,却不练习自己面对陌生问题,那你很难真的学会。所以我是真的非常建议你在这里偏离教程,自己把游戏的物理和碰撞部分做出来。
In any case, you can download the windfield library and require it in the main.lua file. According to its documentation there are the two main concepts of a World and a Collider. The World is the physics world that the simulation happens in, and the Colliders are the physics objects that are being simulated inside that world. So our game will need to have a physics world like and the player will be a collider inside that world.
当然,如果你还是打算继续用 windfield,那就把它下载下来并在 main.lua 里引入。按照它的文档,这个库里有两个核心概念:World 和 Collider。World 是物理模拟发生的世界,Collider 则是存在于这个世界里、参与模拟的物理对象。所以我们的游戏会有一个物理世界,而玩家会是这个世界中的一个 collider。
We'll create a world inside the Area class by adding an addPhysicsWorld call:
我们先在 Area 类里加一个 addPhysicsWorld 调用,用来创建世界:
function Area:addPhysicsWorld()
self.world = Physics.newWorld(0, 0, true)
endThis will set the area's .world attribute to contain the physics world. We also need to update that world (and optionally draw it for debugging purposes) if it exists:
这样就会把物理世界存进 Area 的 .world 属性里。如果这个世界存在,我们还得在每一帧更新它,必要时也可以把它画出来做调试:
function Area:update(dt)
if self.world then self.world:update(dt) end
for i = #self.game_objects, 1, -1 do
...
end
end
function Area:draw()
if self.world then self.world:draw() end
for _, game_object in ipairs(self.game_objects) do game_object:draw() end
endWe update the physics world before updating all the game objects because we want to use up to date information for our game objects, and that will happen only after the physics simulation is done for this frame. If we were updating the game objects first then they would be using physics information from the last frame and this sort of breaks the frame boundary. It doesn't really change the way things work much as far as I can tell but it's conceptually more confusing.
之所以先更新物理世界、再更新所有游戏对象,是因为我们希望对象拿到的是当前帧最新的物理结果,而这只能在本帧的物理模拟先跑完之后才能做到。如果反过来先更新对象,那对象读到的就会是上一帧留下来的物理信息,相当于把帧与帧之间的边界弄混了。就我看来,这对实际效果未必有很大影响,但在概念上会更乱。
The reason we add the world through the addPhysicsWorld call instead of just adding it directly to the Area constructor is because we don't want all Areas to have physics worlds. For instance, the Console room will also use an Area object to handle its entities, but it will not need a physics world attached to that Area. So making it optional through the call of one function makes sense. We can instantiate the physics world in Stage's Area like this:
之所以不是直接在 Area 构造函数里把 world 默认建出来,而是单独做成 addPhysicsWorld,是因为并不是所有 Area 都需要物理世界。比如 Console 房间也会用 Area 来管理对象,但它根本不需要物理世界。所以把这件事做成按需启用的函数会更合理。在 Stage 里,我们可以这样创建它:
function Stage:new()
self.area = Area(self)
self.area:addPhysicsWorld()
...
endAnd so now that we have a world we can add the Player's collider to it:
现在既然已经有了 world,就可以把 Player 的 collider 加进去:
function Player:new(area, x, y, opts)
Player.super.new(self, area, x, y, opts)
self.x, self.y = x, y
self.w, self.h = 12, 12
self.collider = self.area.world:newCircleCollider(self.x, self.y, self.w)
self.collider:setObject(self)
endNote how the player having a reference to the Area comes in handy here, because that way we can access the Area's World to add new colliders to it. This pattern (of accessing things inside the Area) repeats itself a lot, which is I made it so that all GameObject objects have this same constructor where they receive a reference to the Area object they belong to.
这里也能看出来,Player 持有 Area 的引用是很方便的,因为这样我们就能直接通过 Area 拿到 World,并往里面添加新的 collider。后面这种“通过 Area 去访问别的东西”的模式会不断重复出现,所以我才把所有 GameObject 的构造函数统一设计成这样,让它们都接收一个所属 Area 的引用。
In any case, in the Player's constructor we defined its width and height to be 12 via the w and h attributes. Then we add a new CircleCollider with the radius set to the width. It doesn't make much sense now to make the collider a circle while having width and height defined but it will in the future, because as we add different types of ships that the player can be, visually the ships will have different widths and heights, but physically the collider will always be a circle for fairness between different ships as well as predictability to how they feel.
在 Player 的构造函数里,我们先通过 w 和 h 把它的宽高都设成 12。接着创建一个新的 CircleCollider,半径直接用这个宽度。现在看起来,一边把碰撞体做成圆,一边又保留宽高,好像有点多余,但后面你就会发现这是有意义的。因为随着我们加入不同类型的飞船,它们在视觉上的宽高会各不相同;但从物理判定上来说,碰撞体依然统一保持成圆,这样不同飞船之间会更公平,手感上也更可预期。
After the collider is added we call the setObject function which binds the Player object to the Collider we just created. This is useful because when two Colliders collide, we can get information in terms of Colliders but not in terms of objects. So, for instance, if the Player collides with a Projectile we will have in our hands two colliders that represent the Player and the Projectile but we might not have the objects themselves. Using setObject (and getObject) allows us to set and then extract the object that a Collider belongs to.
创建完 collider 之后,我们会调用 setObject,把刚刚这个 collider 和 Player 对象绑定起来。这很有用,因为两个 Collider 发生碰撞时,我们通常能拿到的是两个 collider,而不是两个真正的游戏对象。比如 Player 撞上了 Projectile,我们手上会有代表它们的两个 collider,但不一定直接拿得到 Player 和 Projectile 本身。通过 setObject 和 getObject,就可以把 collider 对应回它所属的对象。
Finally now we can draw the Player according to its size:
最后,我们就可以按它的尺寸把 Player 画出来:
function Player:draw()
love.graphics.circle('line', self.x, self.y, self.w)
endIf you run the game now you should see a small circle that is the Player:
现在运行游戏,你应该会看到一个小圆,这就是 Player:

Player Physics Exercises
If you chose to do collisions yourself or decided to use one of the alternative libraries for collisions/physics then you don't need to do these exercises.
如果你已经决定自己写碰撞逻辑,或者改用了别的碰撞/物理库,那这一节的练习可以不做。
67. Change the physics world's y gravity to 512. What happens to the Player object?
67. 把物理世界的 y 轴重力改成 512。Player 会发生什么变化?
68. What does the third argument of the .newWorld call do and what happens if it's set to false? Are there advantages/disadvantages to setting it to true/false? What are those?
68. .newWorld 调用里的第三个参数是做什么的?如果把它设成 false 会发生什么?设成 true 和 false 各自有什么优缺点?
Player Movement
The way movement for the Player works in this game is that there's a constant velocity that you move at and an angle that can be changed by holding left or right. To get that to work we need a few variables:
这个游戏里,Player 的移动方式是这样的:飞船始终会以某个速度向前移动,而玩家可以通过按住左右键来改变前进角度。为了实现这一点,我们需要几个变量:
function Player:new(area, x, y, opts)
Player.super.new(self, area, x, y, opts)
...
self.r = -math.pi/2
self.rv = 1.66*math.pi
self.v = 0
self.max_v = 100
self.a = 100
endHere I define r as the angle the player is moving towards. It starts as -math.pi/2, which is pointing up. In LÖVE angles work in a clockwise way, meaning math.pi/2 is down and -math.pi/2 is up (and 0 is right). Next, the rv variable represents the velocity of angle change when the user presses left or right. Then we have v, which represents the player's velocity, and then max_v, which represents the maximum velocity possible. The last attribute is a, which represents the player's acceleration. These were all arrived at by trial and error.
这里的 r 表示玩家当前朝向的角度。初始值是 -math.pi/2,也就是朝上。在 LÖVE 里,角度是按顺时针方向计算的,所以 math.pi/2 表示向下,-math.pi/2 表示向上,而 0 则是向右。接下来,rv 表示当玩家按住左右键时,角度变化的速度。然后 v 是玩家当前速度,max_v 是允许达到的最大速度,最后 a 表示加速度。这几个数值基本都是靠试出来的。
To update the player's position using all these variables we can do something like this:
要用这些变量来更新玩家位置,代码可以写成这样:
function Player:update(dt)
Player.super.update(self, dt)
if input:down('left') then self.r = self.r - self.rv*dt end
if input:down('right') then self.r = self.r + self.rv*dt end
self.v = math.min(self.v + self.a*dt, self.max_v)
self.collider:setLinearVelocity(self.v*math.cos(self.r), self.v*math.sin(self.r))
endThe first two lines define what happens when the user presses the left or right keys. It's important to note that according to the Input library we're using those bindings had to be defined beforehand, and I did so in main.lua (since we'll use a global Input object for everything):
前两行定义的是玩家按下左右键时会发生什么。要注意的是,按我们的 Input 库用法,这些动作绑定必须事先定义好。我是在 main.lua 里做的,因为整个游戏都会共用一个全局 Input 对象:
function love.load()
...
input:bind('left', 'left')
input:bind('right', 'right')
...
endAnd so whenever the user pressed left or right, the r attribute, which corresponds to the player's angle, will be changed by 1.66*math.pi radians in the appropriate direction. One important thing to note here is that this value is being multiplied by dt, which essentially means that this value is operating on a per second basis. So the angle at which the angle change happens is 1.66*math.pi radians per second. This is a result of how the game loop that we went over in the first article works.
这样一来,每当玩家按下左键或右键,代表玩家朝向角度的 r 就会按正确方向变化 1.66*math.pi 弧度。这里很关键的一点是,这个值乘上了 dt,也就是说它本质上是按“每秒”来计算的。所以真正的转向速度是每秒 1.66*math.pi 弧度。这一点正是来自我们在第一篇里讲过的游戏循环机制。
After this, we set the v attribute. This one is a bit more involved, but if you've done this in other languages it should be familiar. The original calculation is self.v = self.v + self.a*dt, which is just increasing the velocity by the acceleration. In this case, we increase it by 100 per second. But we also defined the max_v attribute, which should cap the maximum velocity allowed. If we don't cap the maximum velocity allowed then self.v = self.v + self.a*dt will keep increasing v forever with no end and the Player will become Sonic. We don't want that! And so one way to prevent that from happening would be like this:
接着我们来设置 v。这一段稍微复杂一点,不过如果你在别的语言里写过类似逻辑,应该不会陌生。最原始的计算其实就是 self.v = self.v + self.a*dt,也就是让速度按加速度持续增长。这里相当于每秒增加 100。可别忘了我们还定义了 max_v,它应该把最大速度卡住。如果不加这个上限,那么 self.v = self.v + self.a*dt 会让 v 永远不停地往上长,最后 Player 会快成索尼克。显然我们不想这样。所以一种更原始的写法会是:
function Player:update(dt)
...
self.v = self.v + self.a*dt
if self.v >= self.max_v then
self.v = self.max_v
end
...
endIn this way, whenever v went over max_v it would be capped at that value instead of going over it. Another shorthand way of writing this is by using the math.min function, which returns the minimum value of all arguments that are passed to it. In this case we're passing in the result of self.v + self.a*dt and self.max_v, which means that if the result of the addition goes over max_v, math.min will return max_v, since its smaller than the addition. This is a very common and useful pattern in Lua (and in other languages as well).
这样一来,v 一旦超过 max_v,就会被强行钳在那个值上,不再继续变大。更简洁的写法,就是用 math.min。它会返回所有传入参数里较小的那个值。这里我们传进去的是 self.v + self.a*dt 和 self.max_v,所以如果前者已经比 max_v 还大,math.min 自然就会返回更小的 max_v。这在 Lua 里是很常见、也很好用的一种写法,在别的语言里也一样常见。
Finally, we set the Collider's x and y velocity to the v attribute multiplied by the appropriate amount given the angle of the object using setLinearVelocity. In general whenever you want to move something in a direction and you have an angle to work with, you want to use cos to move it along the x axis and sin to move it along the y axis. This is also a very common pattern in 2D gamedev in general. I'm going to assume that you learned why this makes sense in school (and if you haven't then look up basic trigonometry on Google).
最后,我们通过 setLinearVelocity,把 Collider 在 x 和 y 方向上的速度设成基于 v 和当前角度计算出来的值。一般来说,只要你要让某个物体沿着某个角度移动,就会用 cos 处理 x 轴分量,用 sin 处理 y 轴分量。这在 2D 游戏开发里也是非常常见的模式。至于为什么这样成立,我默认你在学校里已经学过;如果没有,就去补一下基础三角函数。
The final change we can make is one to the GameObject class and it's a simple one. Because we're using a physics engine we essentially have two representations of some variables, like position and velocity. We have the player's position and velocity through x, y and v attributes, and we have the Collider's position and velocity through getPosition and getLinearVelocity. It's a good idea to keep both of those representations synced, and one way to achieve that sort of automatically is by changing the parent class of all game objects:
最后还可以对 GameObject 类做一个很简单但很有用的修改。因为我们现在用了物理引擎,所以像位置、速度这样的信息,实际上会有两套表示方式:一套是对象自己的 x, y 和 v;另一套则是 Collider 里的 getPosition 和 getLinearVelocity。最好让这两套表示始终保持同步,而一个比较省心的做法,就是直接改所有游戏对象的父类逻辑:
function GameObject:update(dt)
if self.timer then self.timer:update(dt) end
if self.collider then self.x, self.y = self.collider:getPosition() end
endAnd so here we simply that if the object has a collider attribute defined, then x and y will be set to the position of that collider. And so whenever the collider's position changes, the representation of that position in the object itself will also change accordingly.
这里的意思很简单:如果对象身上定义了 collider,那就把它自己的 x 和 y 直接同步成 collider 的位置。这样一来,只要 collider 的位置发生变化,对象自身记录的位置也会跟着变。
If you run the program now you should see this:
现在运行程序,你应该会看到这样:

And so you can see that the Player object moves around normally and changes its direction when left or right arrow keys are pressed. One detail that's important here is that what is being drawn is the Collider via the world:draw() call in the Area object. We don't really want to draw colliders only, so it makes sense to comment that line out and draw the Player object directly:
可以看到,Player 已经能正常移动,并且在按左右方向键时改变朝向。这里有一个细节要注意:当前真正被画出来的,其实是 Area 里 world:draw() 画出来的 Collider。可我们并不想以后一直只画碰撞体,所以更合理的做法是把那一行先注释掉,转而直接绘制 Player 对象本身:
function Player:draw()
love.graphics.circle('line', self.x, self.y, self.w)
endOne last useful thing we can do is visualize the direction that the player is heading towards. And this can be done by just drawing a line from the player's position that points in the direction he's heading:
最后还有一个很实用的小改动,就是把玩家当前朝向可视化出来。做法也很简单,只要从玩家的位置画出一条指向当前前进方向的线:
function Player:draw()
love.graphics.circle('line', self.x, self.y, self.w)
love.graphics.line(self.x, self.y, self.x + 2*self.w*math.cos(self.r), self.y + 2*self.w*math.sin(self.r))
endAnd that looks like this:
效果像这样:

This again is basic trigonometry and uses the same idea as I explained a while ago. Generally whenever you want to get a position B that is distance units away from position A such that position B is positioned at a specific angle in relation to position A, the pattern is something like: bx = ax + distance*math.cos(angle) and by = ay + distance*math.sin(angle). Doing this is a very very common occurence in 2D gamedev (in my experience at least) and so getting an instinctive handle on how this works is useful.
这依旧是基础三角函数,和前面讲的是同一套思路。一般来说,如果你想从位置 A 出发,求出一个和它相距 distance、并且位于某个特定 angle 方向上的位置 B,常见公式就是:bx = ax + distance*math.cos(angle) 和 by = ay + distance*math.sin(angle)。至少以我的经验来看,这在 2D 游戏开发里真的非常常见,所以最好把这套计算练到比较顺手。
Player Movement Exercises
69. Convert the following angles to degrees (in your head) and also say which quadrant they belong to (top-left, top-right, bottom-left or bottom-right). Notice that in LÖVE the angles are counted in a clockwise manner instead of an anti-clockwise one like you learned in school.
69. 请心算把下面这些角度换算成角度制,并判断它们分别落在哪个象限里(左上、右上、左下或右下)。注意,在 LÖVE 里角度是按顺时针方向算的,不是你在学校里学的逆时针。
math.pi/2
math.pi/4
3*math.pi/4
-5*math.pi/6
0
11*math.pi/12
-math.pi/6
-math.pi/2 + math.pi/4
3*math.pi/4 + math.pi/3
math.pi70. Does the acceleration attribute a need to exist? How could would the player's update function look like if it didn't exist? Are there any benefits to it being there at all?
70. 加速度属性 a 一定有存在的必要吗?如果没有它,玩家的 update 函数会写成什么样?保留它到底有没有什么实际好处?
71. Get the (x, y) position of point B from position A if the angle to be used is -math.pi/4, and the distance is 100.
71. 已知要使用的角度是 -math.pi/4,距离是 100,请从位置 A 求出点 B 的 (x, y) 坐标。

72. Get the (x, y) position of point C from position B if the angle to be used is math.pi/4, and the distance if 50. The position A and B and the distance and angle between them are the same as the previous exercise.
72. 如果从位置 B 出发,使用的角度是 math.pi/4,距离是 50,请继续求出点 C 的 (x, y) 坐标。点 A 到点 B 的位置关系、距离和角度与上一题相同。

73. Based on the previous two exercises, what's the general pattern involved when you want to get from point A to some point C when you all have to use are multiple points in between that all can be reached at through angles and distances?
73. 结合前面两题,如果你想从点 A 最终推到某个点 C,中间还要经过若干个都通过“角度 + 距离”才能到达的中间点,那么这里面的一般规律是什么?
74. The syncing of both representations of Player attributes and Collider attributes mentioned positions and velocities, but what about rotation? A collider has a rotation that can be accessed through getAngle. Why not also sync that to the r attribute?
74. 前面提到要同步 Player 自身属性和 Collider 属性时,讲了位置和速度,那旋转呢?Collider 也有旋转角度,可以通过 getAngle 取得。为什么不顺手也把它同步到 r 属性上?
Garbage Collection
Now that we've added the physics engine code and some movement code we can focus on something important that I've been ignoring until now, which is dealing with memory leaks. One of the things that can happen in any programming environment is that you'll leak memory and this can have all sorts of bad effects. In a managed language like Lua this can be an even more annoying problem to deal with because things are more hidden behind black boxes than if you had full control of memory yourself.
现在既然已经把物理引擎和移动逻辑加进来了,我们可以回头处理一个我之前一直先放着没谈的重要问题:内存泄漏。无论在哪种编程环境里,内存泄漏都有可能发生,而且会带来各种糟糕后果。像 Lua 这种托管语言里,这个问题甚至更烦,因为很多东西都被黑盒封装起来了,不像你自己直接掌控内存时那么一目了然。
The way the garbage collector works is that when there are no more references pointing to an object it will eventually be collected. So if you have a table that is only being referenced to by variable a, once you say a = nil, the garbage collector will understand that the table that was being referenced isn't being referenced by anything and so that table will be removed from memory in a future garbage collection cycle. The problem happens when a single object is being referenced to multiple times and you forget to dereference it in all those locations.
垃圾回收器的基本规则是:当一个对象不再被任何引用指向时,它迟早会被回收。比如说,有一张表只被变量 a 引用着,那当你执行 a = nil 后,垃圾回收器就会知道这张表已经没有任何地方再引用它了,于是在之后的某次回收周期里把它从内存中清掉。真正麻烦的是,同一个对象可能在很多地方都被引用着,而你偏偏漏掉了其中某几处,没有把它们一并解除引用。
For instance, when we create a new object with addGameObject what happens is that the object gets added to a .game_objects list. This counts as one reference pointing to that object. What we also do in that function, though, is return the object itself. So previously we did something like self.player = self.area:addGameObject('Player', ...), which means that on top of holding a reference to the object in the list inside the Area object, we're also holding a reference to it in the self.player variable. Which means that when we say self.player.dead and the Player object gets removed from the game objects list in the Area object, it will still not be collected because self.player is still pointing to it. So in this instance, to truly remove the Player object from memory we have to both set dead to true and then say self.player = nil.
比如,调用 addGameObject 创建新对象时,这个对象会被加入 .game_objects 列表,这本身就算一次引用。与此同时,这个函数还会把对象本身返回出来。所以前面我们写过 self.player = self.area:addGameObject('Player', ...),这就意味着:除了 Area 对象内部的列表持有了一次引用之外,self.player 这里也额外持有了一次引用。于是当我们把 self.player.dead 设为真、Player 从 Area 的游戏对象列表里被移除之后,它依然不会被回收,因为 self.player 还在指着它。所以在这种情况下,如果你真想把 Player 从内存里清掉,就必须既把 dead 设成 true,又把 self.player = nil。
This is just one example of how it could happen but this is a problem that can happen everywhere, and you should be especially careful about it when using other people's libraries. For instance, the physics library I built has a setObject function in which you pass in the object so that the Collider holds a reference to it. If the object dies will it be removed from memory? No, because the Collider is still holding a reference to it. Same problem, just in a different setting. One way of solving this problem is being explicit about the destruction of objects by having a destroy function for them, which will take care of dereferencing things.
这只是其中一个例子,但这种问题其实哪里都可能出现,尤其是在使用别人写的库时更要小心。比如我写的物理库里有个 setObject 函数,你把对象传进去之后,Collider 就会持有对它的引用。那如果这个对象死了,它会自动从内存里消失吗?不会,因为 Collider 还在引用它。问题本质是一样的,只是换了个场景而已。解决这类问题的一个办法,就是显式地为对象提供一个 destroy 函数,让它专门负责解除这些引用。
So, one thing we can add to all objects this:
所以我们可以给所有对象加上这样一段:
function GameObject:destroy()
self.timer:destroy()
if self.collider then self.collider:destroy() end
self.collider = nil
endSo now all objects have this default destroy function. This function calls the destroy functions of the EnhancedTimer object as well as the Collider's one. What these functions do is essentially dereference things that the user will probably want removed from memory. For instance, inside Collider:destroy, one of the things that happens is that self:setObject(nil) is called, since if we want to destroy this object we don't want the Collider holding a reference to it anymore.
这样一来,所有对象就都有一个默认的 destroy 函数了。它会调用 EnhancedTimer 对象的 destroy,以及 Collider 自己的 destroy。这些函数本质上做的,就是把那些用户大概率不想继续留在内存里的引用断开。比如在 Collider:destroy 里,其中一步就是调用 self:setObject(nil),因为既然我们已经要销毁这个对象,就不该再让 Collider 继续引用它。
And then we can also change our Area update function like this:
接着,我们还可以把 Area 的 update 改成这样:
function Area:update(dt)
if self.world then self.world:update(dt) end
for i = #self.game_objects, 1, -1 do
local game_object = self.game_objects[i]
game_object:update(dt)
if game_object.dead then
game_object:destroy()
table.remove(self.game_objects, i)
end
end
endIf an object's dead attribute is set to true, then on top of removing it from the game objects list, we also call its destroy function, which will get rid of most references to it. We can expand this concept further and realize that the physics world itself also has a World:destroy, and so we might want to use it when destroying an Area object:
如果某个对象的 dead 属性被设成了 true,那现在除了把它从游戏对象列表里移除之外,我们还会额外调用它的 destroy 函数,把大部分引用一起清掉。这个思路还可以再往外推一步:物理世界本身也有一个 World:destroy,所以在销毁 Area 时,我们也应该顺手把 world 一并销毁掉:
function Area:destroy()
for i = #self.game_objects, 1, -1 do
local game_object = self.game_objects[i]
game_object:destroy()
table.remove(self.game_objects, i)
end
self.game_objects = {}
if self.world then
self.world:destroy()
self.world = nil
end
endWhen destroying an Area we first destroy all objects in it and then we destroy the physics world if it exists. We can now change the Stage room to accomodate for this:
销毁 Area 时,我们会先销毁里面所有对象,再销毁物理世界(如果它存在的话)。对应地,Stage 房间也可以改成这样:
function Stage:destroy()
self.area:destroy()
self.area = nil
endAnd then we can also change the gotoRoom function:
然后再顺手改一下 gotoRoom:
function gotoRoom(room_type, ...)
if current_room and current_room.destroy then current_room:destroy() end
current_room = _G[room_type](...)
endWe check to see if current_room is a variable that exists and if it contains a destroy attribute (basically we ask if its holding an actual room), and if it does then we call the destroy function. And then we proceed with changing to the target room.
这里会先检查 current_room 这个变量是不是存在,以及它身上有没有 destroy 这个属性,也就是大致确认它现在确实装着一个房间对象。如果有,就先调用它的销毁逻辑,然后再切换到目标房间。
It's important to also remember that now with the addition of the destroy function, all objects have to follow the following template:
另外还要记住一点:既然现在加进了 destroy,那所有对象类以后都得遵循下面这个模板:
NewGameObject = GameObject:extend()
function NewGameObject:new(area, x, y, opts)
NewGameObject.super.new(self, area, x, y, opts)
end
function NewGameObject:update(dt)
NewGameObject.super.update(self, dt)
end
function NewGameObject:draw()
end
function NewGameObject:destroy()
NewGameObject.super.destroy(self)
endNow, this is all well and good, but how do we test to see if we're actually removing things from memory or not? One blog post I like that answers that question is this one, and it offers a relatively simple solution to track leaks:
说了这么多,问题来了:我们要怎么验证这些对象到底有没有真的从内存里消失?关于这一点,我很喜欢的一篇文章是这篇,里面给了一个相对简单的方法来追踪泄漏:
function count_all(f)
local seen = {}
local count_table
count_table = function(t)
if seen[t] then return end
f(t)
seen[t] = true
for k,v in pairs(t) do
if type(v) == "table" then
count_table(v)
elseif type(v) == "userdata" then
f(v)
end
end
end
count_table(_G)
end
function type_count()
local counts = {}
local enumerate = function (o)
local t = type_name(o)
counts[t] = (counts[t] or 0) + 1
end
count_all(enumerate)
return counts
end
global_type_table = nil
function type_name(o)
if global_type_table == nil then
global_type_table = {}
for k,v in pairs(_G) do
global_type_table[v] = k
end
global_type_table[0] = "table"
end
return global_type_table[getmetatable(o) or 0] or "Unknown"
endI'm not going to over what this code does because its explained in the article, but add it to main.lua and then add this inside love.load:
这段代码具体做了什么,我就不在这里逐行展开了,文章里已经解释得很清楚。你把它加进 main.lua 后,再在 love.load 里补上下面这段:
function love.load()
...
input:bind('f1', function()
print("Before collection: " .. collectgarbage("count")/1024)
collectgarbage()
print("After collection: " .. collectgarbage("count")/1024)
print("Object count: ")
local counts = type_count()
for k, v in pairs(counts) do print(k, v) end
print("-------------------------------------")
end)
...
endAnd so what this does is that whenever you press f1, it will show you the amount of memory before a garbage collection cycle and the amount of memory after it, as well as showing you what object types are in memory. This is useful because now we can, for instance, create a new Stage full of objects, delete it, and then see if the memory remains the same (or acceptably the same hehexD) as it was before the Stage was created. If it remains the same then we aren't leaking memory, if it doesn't then it means we are and we need to track down the cause of it.
这样一来,每当你按下 f1,控制台就会显示一次垃圾回收前的内存占用、垃圾回收后的内存占用,以及当前内存里都有哪些对象类型。这就很有用了,因为我们现在可以比如先创建一个塞满对象的 Stage,再把它销毁掉,然后观察内存是否回到了创建之前的水平,哪怕只是“差不多回去”也行 hehexD。如果差不多回去了,就说明没有明显泄漏;如果回不去,那就说明确实有泄漏,我们得继续往下查原因。

Garbage Collection Exercises
75. Bind the f2 key to create and activate a new Stage with a gotoRoom call.
75. 把 f2 键绑定成通过 gotoRoom 创建并切换到一个新的 Stage。
76. Bind the f3 key to destroy the current room.
76. 把 f3 键绑定成销毁当前房间。
77. Check the amount of memory used by pressing f1 a few times. After that spam the f2 and f3 keys a few times to create and destroy new rooms. Now check the amount of memory used again by pressing f1 a few times again. Is it the same as the amount of memory as it was first or is it more?
77. 先按几次 f1,看看当前内存占用。然后疯狂按几次 f2 和 f3,反复创建、销毁新房间。之后再按几次 f1 检查内存。现在看到的内存占用,和一开始相比是差不多,还是明显变多了?
78. Set the Stage room to spawn 100 Player objects instead of only 1 by doing something like this:
78. 让 Stage 房间一次生成 100 个 Player,而不是只生成 1 个。可以像这样写:
function Stage:new()
...
for i = 1, 100 do
self.area:addGameObject('Player', gw/2 + random(-4, 4), gh/2 + random(-4, 4))
end
endAlso change the Player's update function so that Player objects don't move anymore (comment out the movement code). Now repeat the process of the previous exercise. Is the amount of memory used different? And do the overall results change?
同时把 Player 的 update 改一下,让这些 Player 都不再移动,也就是把移动相关代码先注释掉。然后重复上一题的测试流程。这一次的内存占用有没有不同?整体结论会不会发生变化?