跳转至内容

BYTEPATH #2 - Libraries

Introduction

In this article we'll cover a few Lua/LÖVE libraries that are necessary for the project and we'll also explore some ideas unique to Lua that you should start to get comfortable with. There will be a total of 4 libraries used by the end of it, and part of the goal is to also get you used to the idea of downloading libraries built by other people, reading through the documentation of those and figuring out how they work and how you can use them in your game. Lua and LÖVE don't come with lots of features by themselves, so downloading code written by other people and using it is a very common and necessary thing to do.

这一篇会介绍几个这个项目里必需用到的 Lua/LÖVE 库,同时也会顺带讲一些 Lua 特有、你最好尽快熟悉起来的思路。到最后一共会用到 4 个库,而这篇的目的之一,就是让你习惯去下载别人写好的库、阅读它们的文档、弄明白它们是怎么工作的,以及怎样把它们用进自己的游戏里。Lua 和 LÖVE 本身提供的功能并不算多,所以拿来就用别人的代码,在这里是很常见、也很有必要的做法。

Object Orientation

The first thing I'll cover here is object orientation. There are many many different ways to get object orientation working with Lua, but I'll just use a library. The OOP library I like the most is rxi/classic because of how small and effective it is. To install it just download it and drop the classic folder inside the project folder. Generally I create a libraries folder and drop all libraries there.

先说面向对象。Lua 里实现面向对象的方法多得很,不过这里我直接用库。OOP 库里我最喜欢的是 rxi/classic,因为它足够小,也足够好用。安装方法很简单,下载下来之后把 classic 文件夹丢进项目目录就行。一般我会专门建一个 libraries 文件夹,把所有第三方库都放进去。

Once that's done you can import the library to the game at the top of the main.lua file by doing:

弄好以后,在 main.lua 顶部这样把它引进来:

lua
Object = require 'libraries/classic/classic'

As the github page states, you can do all the normal OOP stuff with this library and it should work fine. When creating a new class I usually do it in a separate file and place that file inside an objects folder. So, for instance, creating a Test class and instantiating it once would look like this:

就像 github 页面里写的那样,这个库能满足常规的 OOP 用法,基本够用了。新建类的时候,我通常会把每个类放在单独的文件里,再把这些文件统一放进 objects 文件夹。比如,定义一个 Test 类并实例化一次,大概就是这样:

lua
-- in objects/Test.lua
Test = Object:extend()

function Test:new()

end

function Test:update(dt)

end

function Test:draw()

end
lua
-- in main.lua
Object = require 'libraries/classic/classic'
require 'objects/Test'

function love.load()
    test_instance = Test()
end

So when require 'objects/Test' is called in main.lua, everything that is defined in the Test.lua file happens, which means that the Test global variable now contains the definition for the Test class. For this game, every class definition will be done like this, which means that class names must be unique since they are bound to a global variable. If you don't want to do things like this you can make the following changes:

所以,当 main.lua 里调用 require 'objects/Test' 时,Test.lua 里定义的东西都会被执行,这也就意味着全局变量 Test 此时已经装进了这个类的定义。在这个项目里,我会一直沿用这种写法,因此类名必须彼此唯一,因为它们最终都绑在全局变量上。如果你不想这么干,也可以改成下面这种:

lua
-- in objects/Test.lua
local Test = Object:extend()
...
return Test
lua
-- in main.lua
Test = require 'objects/Test'

By defining the Test variable as local in Test.lua it won't be bound to a global variable, which means you can bind it to whatever name you want when requiring it in main.lua. At the end of the Test.lua script the local variable is returned, and so in main.lua when Test = require 'objects/Test' is declared, the Test class definition is being assigned to the global variable Test.

Test.lua 里的 Test 定义成局部变量后,它就不会自动占用全局变量了,也就是说,你在 main.luarequire 它的时候,想把它赋给什么名字都可以。因为 Test.lua 结尾把这个局部变量 return 了,所以 main.lua 里写下 Test = require 'objects/Test' 时,实际上就是把这个类定义赋值给了全局变量 Test

Sometimes, like when writing libraries for other people, this is a better way of doing things so you don't pollute their global state with your library's variables. This is what classic does as well, which is why you have to initialize it by assigning it to the Object variable. One good result of this is that since we're assigning a library to a variable, if you wanted to you could have named Object as Class instead, and then your class definitions would look like Test = Class:extend().

有时候,尤其是在给别人写库时,这种写法会更合适,因为你不会把自己库里的变量随手塞进别人的全局环境里。classic 本身也是这么做的,这就是为什么你需要先把它赋给 Object 这个变量再用。这样还有个好处:既然库名只是你手动赋给变量的,你完全可以不用 Object,而改叫 Class,那类定义就会变成 Test = Class:extend()

One last thing that I do is to automate the require process for all classes. To add a class to the environment you need to type require 'objects/ClassName'. The problem with this is that there will be lots of classes and typing it for every class can be tiresome. So something like this can be done to automate that process:

我还会做最后一件事,就是把所有类的 require 过程自动化。正常情况下,要把一个类载入环境里,你得手动写 require 'objects/ClassName'。问题在于类一多,这件事很快就会变得又烦又机械。所以可以像下面这样,把这一步自动完成:

lua
function love.load()
    local object_files = {}
    recursiveEnumerate('objects', object_files)
end

function recursiveEnumerate(folder, file_list)
    local items = love.filesystem.getDirectoryItems(folder)
    for _, item in ipairs(items) do
        local file = folder .. '/' .. item
        if love.filesystem.isFile(file) then
            table.insert(file_list, file)
        elseif love.filesystem.isDirectory(file) then
            recursiveEnumerate(file, file_list)
        end
    end
end

So let's break this down. The recursiveEnumerate function recursively enumerates all files inside a given folder and adds them as strings to a table. It makes use of LÖVE's filesystem module, which contains lots of useful functions for doing stuff like this.

我们拆开来看。recursiveEnumerate 这个函数会递归遍历指定文件夹里的所有文件,并把它们的路径以字符串形式塞进一张表里。这里用到的是 LÖVE 的 filesystem 模块,它里头有不少处理这类事情很方便的函数。

The first line inside the loop lists all files and folders in the given folder and returns them as a table of strings using love.filesystem.getDirectoryItems. Next, it iterates over all those and gets the full file path of each item by concatenating (concatenation of strings in Lua is done by using ..) the folder string and the item string.

循环体里的第一行会用 love.filesystem.getDirectoryItems 列出目标目录中的所有文件和文件夹,并把结果作为字符串表返回。接着代码会遍历这些条目,把 folderitem 两个字符串拼起来,得到每个条目的完整路径。在 Lua 里,字符串拼接用的是 ..

Let's say that the folder string is 'objects' and that inside the objects folder there is a single file named GameObject.lua. And so the items list will look like items = {'GameObject.lua'}. When that list is iterated over, the local file = folder .. '/' .. item line will parse to local file = 'objects/GameObject.lua', which is the full path of the file in question.

假设 folder 的值是 'objects',而 objects 文件夹里只有一个叫 GameObject.lua 的文件。那么 items 这张表看起来就会是 items = {'GameObject.lua'}。遍历到它时,local file = folder .. '/' .. item 这一行就会得到 local file = 'objects/GameObject.lua',也就是这个文件的完整路径。

Then, this full path is used to check if it is a file or a directory using the love.filesystem.isFile and love.filesystem.isDirectory functions. If it is a file then simply add it to the file_list table that was passed in from the caller, otherwise call recursiveEnumerate again, but now using this path as the folder variable. When this finishes running, the file_list table will be full of strings corresponding to the paths of all files inside folder. In our case, the object_files variable will be a table full of strings corresponding to all the classes in the objects folder.

接下来,这个完整路径会交给 love.filesystem.isFilelove.filesystem.isDirectory 去判断它到底是文件还是目录。如果是文件,就把它加入调用方传进来的 file_list 表;如果是目录,就再次调用 recursiveEnumerate,不过这次把当前路径当成新的 folder 去继续往下找。等这段逻辑跑完之后,file_list 里就会装满 folder 下所有文件的路径字符串。在这里,object_files 最终就会是一张包含 objects 文件夹里全部类文件路径的表。

There's still a step left, which is to take all those paths and require them:

不过还差最后一步,就是把这些路径挨个 require 进来:

lua
function love.load()
    local object_files = {}
    recursiveEnumerate('objects', object_files)
    requireFiles(object_files)
end

function requireFiles(files)
    for _, file in ipairs(files) do
        local file = file:sub(1, -5)
        require(file)
    end
end

This is a lot more straightforward. It simply goes over the files and calls require on them. The only thing left to do is to remove the .lua from the end of the string, since the require function spits out an error if it's left in. The line that does that is local file = file:sub(1, -5) and it uses one of Lua's builtin string functions. So after this is done all classes defined inside the objects folder can be automatically loaded. The recursiveEnumerate function will also be used later to automatically load other resources like images, sounds and shaders.

这段就直白多了。它只是遍历这些文件,然后对每个文件调用 require。唯一额外要做的,是把路径末尾的 .lua 去掉,因为如果带着这个后缀去 require,就会报错。负责这件事的是 local file = file:sub(1, -5) 这一行,它用到了 Lua 内建的 字符串函数。做完这一步后,objects 文件夹里的所有类就都能自动加载了。后面我们还会继续用 recursiveEnumerate 来自动加载图片、音效、shader 之类的其他资源。

OOP Exercises

6. Create a Circle class that receives x, y and radius arguments in its constructor, has x, y, radius and creation_time attributes and has update and draw methods. The x, y and radius attributes should be initialized to the values passed in from the constructor and the creation_time attribute should be initialized to the relative time the instance was created (see love.timer). The update method should receive a dt argument and the draw function should draw a white filled circle centered at x, y with radius radius (see love.graphics). An instance of this Circle class should be created at position 400, 300 with radius 50. It should also be updated and drawn to the screen. This is what the screen should look like:

6. 创建一个 Circle 类,它的构造函数接收 xyradius 三个参数,拥有 xyradiuscreation_time 四个属性,并实现 updatedraw 方法。xyradius 这三个属性应当直接初始化为构造函数传入的值,而 creation_time 应初始化为该实例被创建时的相对时间(见 love.timer)。update 方法需要接收一个 dt 参数,draw 方法则要在 x, y 位置绘制一个半径为 radius 的白色实心圆(见 love.graphics)。然后在 (400, 300) 位置创建一个半径为 50Circle 实例,并把它更新和绘制到屏幕上。画面应该像这样:

7. Create an HyperCircle class that inherits from the Circle class. An HyperCircle is just like a Circle, except it also has an outer ring drawn around it. It should receive additional arguments line_width and outer_radius in its constructor. An instance of this HyperCircle class should be created at position 400, 300 with radius 50, line width 10 and outer radius 120. This is what the screen should look like:

7. 创建一个继承自 CircleHyperCircle 类。HyperCircleCircle 基本一样,只不过它外面还多画了一圈外环。它的构造函数还应额外接收 line_widthouter_radius 两个参数。然后在 (400, 300) 位置创建一个 HyperCircle 实例,圆半径为 50、线宽为 10、外环半径为 120。画面应该像这样:

8. What is the purpose of the : operator in Lua? How is it different from . and when should either be used?

8. Lua 里的 : 运算符是做什么用的?它和 . 有什么区别?分别应该在什么场景下使用?

9. Suppose we have the following code:

9. 假设我们有下面这段代码:

lua
function createCounterTable()
    return {
        value = 1,
        increment = function(self) self.value = self.value + 1 end,
    }
end

function love.load()
    counter_table = createCounterTable()
    counter_table:increment()
end

What is the value of counter_table.value? Why does the increment function receive an argument named self? Could this argument be named something else? And what is the variable that self represents in this example?

counter_table.value 的值是多少?为什么 increment 函数要接收一个叫 self 的参数?这个参数能不能换个名字?在这个例子里,self 具体指代的又是什么?

10. Create a function that returns a table that contains the attributes a, b, c and sum. a, b and c should be initiated to 1, 2 and 3 respectively, and sum should be a function that adds a, b and c together. The final result of the sum should be stored in the c attribute of the table (meaning, after you do everything, the table should have an attribute c with the value 6 in it).

10. 创建一个函数,返回一张包含 abcsum 四个属性的表。abc 初始值分别设为 1、2、3,而 sum 则是一个把 abc 三者相加的函数。最终求和结果要存回这张表的 c 属性里,也就是说,全部执行完后,表里的 c 应该变成 6。

11. If a class has a method with the name of someMethod can there be an attribute of the same name? If not, why not?

11. 如果一个类里已经有一个叫 someMethod 的方法,还能不能再有一个同名属性?如果不能,原因是什么?

12. What is the global table in Lua?

12. Lua 里的全局表是什么?

13. Based on the way we made classes be automatically loaded, whenever one class inherits from another we have code that looks like this:

13. 按照我们前面自动加载类的方式,只要一个类继承另一个类,就会出现像下面这样的代码:

lua
SomeClass = ParentClass:extend()

Is there any guarantee that when this line is being processed the ParentClass variable is already defined? Or, to put it another way, is there any guarantee that ParentClass is required before SomeClass? If yes, what is that guarantee? If not, what could be done to fix this problem?

当这行代码被执行时,ParentClass 一定已经定义好了吗?换句话说,ParentClass 一定会在 SomeClass 之前被 require 吗?如果会,这个保证来自哪里?如果不会,又该怎么修正这个问题?

14. Suppose that all class files do not define the class globally but do so locally, like:

14. 假设所有类文件都不再把类定义成全局变量,而是像下面这样定义成局部变量:

lua
local ClassName = Object:extend()
...
return ClassName

How would the requireFiles function need to be changed so that we could still automatically load all classes?

那么 requireFiles 函数要怎么改,才能继续自动加载所有类?

Input

Now for how to handle input. The default way to do it in LÖVE is through a few callbacks. When defined, these callback functions will be called whenever the relevant event happens and then you can hook the game in there and do whatever you want with it:

接下来讲输入处理。LÖVE 默认是靠一组回调函数来处理输入的。只要你定义了这些回调,对应事件发生时它们就会被自动调用,然后你就可以在里面接入自己的游戏逻辑,想做什么都行:

lua
function love.load()

end

function love.update(dt)

end

function love.draw()

end

function love.keypressed(key)
    print(key)
end

function love.keyreleased(key)
    print(key)
end

function love.mousepressed(x, y, button)
    print(x, y, button)
end

function love.mousereleased(x, y, button)
    print(x, y, button)
end

So in this case, whenever you press a key or click anywhere on the screen the information will be printed out to the console. One of the big problems I've always had with this way of doing things is that it forces you to structure everything you do that needs to receive input around these calls.

在这个例子里,不管你按下键盘上的键,还是在屏幕任意位置点击鼠标,相关信息都会被打印到控制台。我一直觉得这种处理方式有个很大的问题,就是凡是需要接收输入的逻辑,你都得围着这些回调去组织代码。

So, let's say you have a game object which has inside it a level object which has inside a player object. To get the player object receive keyboard input, all those 3 objects need to have the two keyboard related callbacks defined, because at the top level you only want to call game:keypressed inside love.keypressed, since you don't want the lower levels to know about the level or the player. So I created a library to deal with this problem. You can download it and install it like the other library that was covered. Here's a few examples of how it works:

比如说,你有一个 game 对象,里面套着 level 对象,level 里面又套着 player 对象。要让 player 接收到键盘输入,这三个对象通常都得定义和键盘相关的两个回调,因为在最上层的 love.keypressed 里,你往往只想调用 game:keypressed,不希望底层对象反过来知道 levelplayer 的存在。为了解决这个问题,我写了一个。它的下载和安装方式跟前面那些库一样,下面是几个用法示例:

lua
function love.load()
    input = Input()
    input:bind('mouse1', 'test')
end

function love.update(dt)
    if input:pressed('test') then print('pressed') end
    if input:released('test') then print('released') end
    if input:down('test') then print('down') end
end

So what the library does is that instead of relying on callback functions for input, it simply asks if a certain key has been pressed on this frame and receives a response of true or false. In the example above on the frame that you press the mouse1 button, pressed will be printed to the screen, and on the frame that you release it, released will be printed. On all the other frames where the press didn't happen the input:pressed or input:released calls would have returned false and so whatever is inside of the conditional wouldn't be run. The same applies to the input:down function, except it returns true on every frame that the button is held down and false otherwise.

这个库的思路是,不再依赖输入回调,而是主动去询问“这一帧某个按键是不是被按下了”,然后拿到一个 truefalse 的结果。上面的例子里,当你按下 mouse1 的那一帧,屏幕上会打印 pressed;当你松开它的那一帧,会打印 released。其余没有发生按下或松开事件的帧里,input:pressedinput:released 都会返回 false,于是条件里的代码就不会执行。input:down 也是一样,只不过它会在按键持续按住的每一帧都返回 true,松开时才返回 false

Often times you want behavior that repeats at a certain interval when a key is held down, instead of happening every frame. For that purpose you can use the down function like this:

很多时候,你想要的不是按住键时每一帧都触发,而是按住后按固定时间间隔重复触发。这种情况可以这样用 down

lua
function love.update(dt)
    if input:down('test', 0.5) then print('test event') end
end

So in this example, once the key bound to the test action is held down, every 0.5 seconds test event will be printed to the console.

在这个例子里,只要绑定到 test 动作的键被按住,每隔 0.5 秒控制台就会打印一次 test event

Input Exercises

15. Suppose we have the following code:

15. 假设有下面这段代码:

lua
function love.load()
    input = Input()
    input:bind('mouse1', function() print(love.math.random()) end)
end

Will anything happen when mouse1 is pressed? What about when it is released? And held down?

当按下 mouse1 时会发生什么吗?松开时呢?持续按住时呢?

16. Bind the keypad + key to an action named add, then increment the value of a variable named sum (which starts at 0) by 1 every 0.25 seconds when the add action key is held down. Print the value of sum to the console every time it is incremented.

16. 把小键盘上的 + 键绑定到一个名为 add 的动作上,然后在 add 对应的键被按住时,每隔 0.25 秒把变量 sum 的值加 1。sum 初始值为 0,并且每次递增后都要把它打印到控制台。

17. Can multiple keys be bound to the same action? If not, why not? And can multiple actions be bound to the same key? If not, why not?

17. 多个按键能不能绑定到同一个动作上?如果不能,为什么?那多个动作能不能绑定到同一个按键上?如果也不能,原因又是什么?

18. If you have a gamepad, bind its DPAD buttons(fup, fdown...) to actions up, left, right and down and then print the name of the action to the console once each button is pressed.

18. 如果你手头有手柄,把它的 DPAD 按键(fupfdown 等)分别绑定到 upleftrightdown 这些动作上,然后在每个按键被按下时,把对应动作名打印到控制台。

19. If you have a gamepad, bind one of its trigger buttons (l2, r2) to an action named trigger. Trigger buttons return a value from 0 to 1 instead of a boolean saying if its pressed or not. How would you get this value?

19. 如果你有手柄,把其中一个扳机键(l2r2)绑定到名为 trigger 的动作上。扳机键返回的不是“是否按下”的布尔值,而是 0 到 1 之间的数值。你要怎样取得这个值?

20. Repeat the same as the previous exercise but for the left and right stick's horizontal and vertical position.

20. 按照上一题同样的思路,再分别处理左右摇杆的水平和垂直位置。

Timer

Now another crucial piece of code to have are general timing functions. For this I'll use hump, more especifically hump.timer.

接下来还需要一组很关键的通用计时函数。这里我会用 hump,更准确地说,是它里面的 hump.timer

lua
Timer = require 'libraries/hump/timer'

function love.load()
    timer = Timer()
end

function love.update(dt)
    timer:update(dt)
end

According to the documentation it can be used directly through the Timer variable or it can be instantiated to a new one instead. I decided to do the latter. I'll use this global timer variable for global timers and then whenever timers inside objects are needed, like inside the Player class, it will have its own timer instantiated locally.

按照文档的说法,你既可以直接通过 Timer 这个变量来用它,也可以像这里这样再实例化出一个新的对象。我选的是后者。这个全局 timer 变量会用来处理全局定时器;而如果某个对象内部也需要自己的计时器,比如 Player 类,就让它自己在局部再实例化一个。

The most important timing functions used throughout the entire game are after, every and tween. And while I personally don't use the script function, some people might find it useful so it's worth a mention. So let's go through them:

整个项目里最常用的计时函数主要有 aftereverytween。至于 script,我自己基本不用,但有人可能会觉得顺手,所以还是值得提一下。下面就一个个过:

lua
function love.load()
    timer = Timer()
    timer:after(2, function() print(love.math.random()) end)
end

after is pretty straightfoward. It takes in a number and a function, and it executes the function after number seconds. In the example above, a random number would be printed to the console 2 seconds after the game is run. One of the cool things you can do with after is that you can chain multiple of those together, so for instance:

after 很好理解。它接收一个数字和一个函数,并在若干秒后执行这个函数。上面的例子里,游戏启动 2 秒后就会往控制台打印一个随机数。after 有个挺方便的地方,就是可以把多个调用串起来,比如:

lua
function love.load()
    timer = Timer()
    timer:after(2, function()
        print(love.math.random())
        timer:after(1, function()
            print(love.math.random())
            timer:after(1, function()
                print(love.math.random())
            end)
        end)
    end)
end

In this example, a random number would be printed 2 seconds after the start, then another one 1 second after that (3 seconds since the start), and finally another one another second after that (4 seconds since the start). This is somewhat similar to what the script function does, so you can choose which one you like best.

这个例子里,游戏开始 2 秒后先打印一个随机数;再过 1 秒,也就是第 3 秒时打印第二个;然后再过 1 秒,也就是第 4 秒时打印第三个。这和 script 能做的事情有点像,用哪个就看你更顺手哪种写法了。

lua
function love.load()
    timer = Timer()
    timer:every(1, function() print(love.math.random()) end)
end

In this example, a random number would be printed every 1 second. Like the after function it takes in a number and a function and executes the function after number seconds. Optionally it can also take a third argument which is the amount of times it should pulse for, so, for instance:

这个例子里,每隔 1 秒就会打印一个随机数。它和 after 一样,也是接收一个数字和一个函数,然后在对应时间后执行函数。不同的是它会周期性触发。另外它还可以选择接收第三个参数,用来指定总共要触发多少次,比如:

lua
function love.load()
    timer = Timer()
    timer:every(1, function() print(love.math.random()) end, 5)
end

Would only print 5 numbers in the first 5 pulses. One way to get the every function to stop pulsing without specifying how many times it should be run for is by having it return false. This is useful for situations where the stop condition is not fixed or known at the time the every call was made.

这样它只会在前 5 次触发时打印 5 个数字。如果你不想提前写死次数,也可以让 every 内部返回 false 来停止继续触发。这在终止条件事先并不固定、或者调用 every 时还不知道什么时候该停的场景里很有用。

Another way you can get the behavior of the every function is through the after function, like so:

其实 every 的效果也可以用 after 自己拼出来,像这样:

lua
function love.load()
    timer = Timer()
    timer:after(1, function(f)
        print(love.math.random())
        timer:after(1, f)
    end)
end

I never looked into how this works internally, but the creator of the library decided to do it this way and document it in the instructions so I'll just take it ^^. The usefulness of getting the funcionality of every in this way is that we can change the time taken between each pulse by changing the value of the second after call inside the first:

我倒是没认真研究过它内部为什么这样就能成立,不过库作者就是这么设计、也这么写进文档里的,那我们直接拿来用就行 ^^。这种写法的价值在于,你可以通过修改第一个 after 里那次“下一次 after 调用”的时间,来动态改变每次触发之间的间隔:

lua
function love.load()
    timer = Timer()
    timer:after(1, function(f)
        print(love.math.random())
        timer:after(love.math.random(), f)
    end)
end

So in this example the time between each pulse is variable (between 0 and 1, since love.math.random returns values in that range by default), something that can't be achieved by default with the every function. Variable pulses are very useful in a number of situations so it's good to know how to do them. Now, on to the tween function:

所以在这个例子里,每次触发之间的时间间隔是变化的,范围在 0 到 1 之间,因为 love.math.random 默认会返回这个区间内的值。像这种可变间隔,是 every 默认做不到的。很多情况下这种不固定节奏都很有用,所以知道怎么实现是件好事。下面来看 tween

lua
function love.load()
    timer = Timer()
    circle = {radius = 24}
    timer:tween(6, circle, {radius = 96}, 'in-out-cubic')
end

function love.update(dt)
    timer:update(dt)
end

function love.draw()
    love.graphics.circle('fill', 400, 300, circle.radius)
end

The tween function is the hardest one to get used to because there are so many arguments, but it takes in a number of seconds, the subject table, the target table and a tween mode. Then it performs the tween on the subject table towards the values in the target table. So in the example above, the table circle has a key radius in it with the initial value of 24. Over the span of 6 seconds this value will changed to 96 using the in-out-cubic tween mode. (here's a useful list of all tweening modes) It sounds complicated but it looks like this:

tween 是这几个函数里最不容易一眼看懂的,因为它参数比较多。它需要接收持续时间、目标对象表、目标值表,以及一种 tween 模式。之后它会把目标对象里的值,朝着目标值表中的内容逐步过渡。上面的例子里,circle 这张表有个 radius 键,初始值是 24;在 6 秒内,这个值会用 in-out-cubic 的插值模式渐变到 96。(所有 tween 模式可以参考这个很实用的列表)听起来有点抽象,但效果是这样的:

The tween function can also take an additional argument after the tween mode which is a function to be called when the tween ends. This can be used for a number of purposes, but taking the previous example, we could use it to make the circle shrink back to normal after it finishes expanding:

tween 在 tween 模式后面还可以再接一个额外参数,也就是当 tween 结束时要调用的函数。这个回调用途很多,还是拿刚才那个例子来说,我们就可以让圆在放大结束后再缩回原来的大小:

lua
function love.load()
    timer = Timer()
    circle = {radius = 24}
    timer:after(2, function()
        timer:tween(6, circle, {radius = 96}, 'in-out-cubic', function()
            timer:tween(6, circle, {radius = 24}, 'in-out-cubic')
        end)
    end)
end

And that looks like this:

效果看起来是这样:

These 3 functions - after, every and tween - are by far in the group of most useful functions in my code base. They are very versatile and they can achieve a lot of stuff. So make you sure you have some intuitive understanding of what they're doing!

aftereverytween 这三个函数,毫无疑问是我整套代码里最好用、也最常用的一组工具。它们非常灵活,能拿来做的事情很多。所以你最好对它们的行为先建立起一种比较直观的理解。


One important thing about the timer library is that each one of those calls returns a handle. This handle can be used in conjunction with the cancel call to abort a specific timer:

关于这个 timer 库,还有个很重要的点:每次调用这些函数时,都会返回一个 handle。这个 handle 可以配合 cancel 使用,用来取消某个特定的计时器:

lua
function love.load()
    timer = Timer()
    local handle_1 = timer:after(2, function() print(love.math.random()) end)
    timer:cancel(handle_1)

So in this example what's happening is that first we call after to print a random number to the console after 2 seconds, and we store the handle of this timer in the handle_1 variable. Then we cancel that call by calling cancel with handle_1 as an argument. This is an extremely important thing to be able to do because often times we will get into a situation where we'll create timed calls based on certain events. Say, when someone presses the key r we want to print a random number to the console after 2 seconds:

这个例子里,先调用 after,让它在 2 秒后往控制台打印一个随机数,并把这个计时器的 handle 存进 handle_1。随后再把 handle_1 传给 cancel,于是刚才那次定时调用就被取消了。这件事非常重要,因为在实际项目里,我们经常会根据某些事件去创建定时调用。比如,用户按下 r 键后,我们想在 2 秒后打印一个随机数:

lua
function love.keypressed(key)
    if key == 'r' then
        timer:after(2, function() print(love.math.random()) end)
    end
end

If you add the code above to the main.lua file and run the project, after you press r a random number should appear on the screen with a delay. If you press r multiple times repeatedly, multiple numbers will appear with a delay in quick succession. But sometimes we want the behavior that if the event happens repeated times it should reset the timer and start counting from 0 again. This means that whenever we press r we want to cancel all previous timers created from when this event happened in the past. One way of doing this is to somehow store all handles created somewhere, bind them to an event identifier of some sort, and then call some cancel function on the event identifier itself which will cancel all timer handles associated with that event. This is what that solution looks like:

把上面这段代码加进 main.lua 并运行后,你按下 r,屏幕就会延迟一会儿再出现一个随机数。如果你连续按很多次 r,就会有很多个延迟触发的随机数接连冒出来。但有时候我们想要的是另一种行为:如果同一个事件重复发生,就把原来的计时重置,从 0 重新开始。也就是说,每次按下 r 时,我们都想取消之前因这个事件而创建的所有旧计时器。一种做法是把所有 handle 存起来,再给它们绑定一个事件标识,之后通过这个事件标识一次性取消和它相关的全部计时器。写出来就是这样:

lua
function love.keypressed(key)
    if key == 'r' then
        timer:after('r_key_press', 2, function() print(love.math.random()) end)
    end
end

I created an enhancement of the current timer module that supports the addition of event tags. So in this case, the event r_key_press is attached to the timer that is created whenever the r key is pressed. If the key is pressed multiple times repeatedly, the module will automatically see that this event has other timers registered to it and cancel those previous timers as a default behavior, which is what we wanted. If the tag is not used then it defaults to the normal behavior of the module.

我在原本的 timer 模块基础上做过一个增强版,支持事件 tag。这里的 r_key_press 就是绑在这个计时器上的事件标识。这样一来,如果你连续多次按 r,模块会自动发现这个事件名下面已经挂过别的计时器,并默认把旧的那些取消掉,这正是我们想要的效果。如果你不用 tag,那它就还是按照原始模块的普通行为来运行。

You can download this enhanced version here and swap the timer import in main.lua from libraries/hump/timer to wherever you end up placing the EnhancedTimer.lua file, I personally placed it in libraries/enhanced_timer/EnhancedTimer. This also assumes that the hump library was placed inside the libraries folder. If you named your folders something different you must change the path at the top of the EnhancedTimer file. Additionally, you can also use this library I wrote which has the same functionality as hump.timer, but also handles event tags in the way I described.

这个增强版可以从这里下载。下载后,把 main.lua 里原本 libraries/hump/timer 的导入路径改成你实际放置 EnhancedTimer.lua 的位置就行。我个人是放在 libraries/enhanced_timer/EnhancedTimer。当然,这也默认你已经把 hump 库放进了 libraries 文件夹;如果你的目录结构不一样,就得自己改 EnhancedTimer 文件顶部的路径。另外,你也可以直接用我写的另一个库 chrono,它既有 hump.timer 的功能,也支持我刚才说的这种事件 tag 机制。

Timer Exercises

21. Using only a for loop and one declaration of the after function inside that loop, print 10 random numbers to the screen with an interval of 0.5 seconds between each print.

21. 只使用一个 for 循环,并且在循环内部只写一次 after 调用,让程序以每隔 0.5 秒的间隔,向屏幕打印 10 个随机数。

22. Suppose we have the following code:

22. 假设我们有下面这段代码:

lua
function love.load()
    timer = Timer()
    rect_1 = {x = 400, y = 300, w = 50, h = 200}
    rect_2 = {x = 400, y = 300, w = 200, h = 50}
end

function love.update(dt)
    timer:update(dt)
end

function love.draw()
    love.graphics.rectangle('fill', rect_1.x - rect_1.w/2, rect_1.y - rect_1.h/2, rect_1.w, rect_1.h)
    love.graphics.rectangle('fill', rect_2.x - rect_2.w/2, rect_2.y - rect_2.h/2, rect_2.w, rect_2.h)
end

Using only the tween function, tween the w attribute of the first rectangle over 1 second using the in-out-cubic tween mode. After that is done, tween the h attribute of the second rectangle over 1 second using the in-out-cubic tween mode. After that is done, tween both rectangles back to their original attributes over 2 seconds using the in-out-cubic tween mode. It should look like this:

只使用 tween 函数,先在 1 秒内以 in-out-cubic 模式对第一个矩形的 w 属性做 tween;等它完成后,再在 1 秒内以同样的模式对第二个矩形的 h 属性做 tween;再之后,用 2 秒时间把两个矩形都 tween 回原始属性值,依然使用 in-out-cubic。最终效果应该像这样:

23. For this exercise you should create an HP bar. Whenever the user presses the d key the HP bar should simulate damage taken. It should look like this:

23. 这一题请你做一个 HP 条。每当用户按下 d 键时,HP 条就要模拟一次受伤掉血,效果应当像这样:

As you can see there are two layers to this HP bar, and whenever damage is taken the top layer moves faster while the background one lags behind for a while.

可以看到,这个 HP 条分成两层。每次掉血时,上层会先快速缩短,底层则会慢半拍再跟上。

24. Taking the previous example of the expanding and shrinking circle, it expands once and then shrinks once. How would you change that code so that it expands and shrinks continually forever?

24. 前面那个圆先放大一次、再缩小一次的例子里,它只执行了一轮。如果想让它一直无限循环地放大、缩小下去,代码该怎么改?

25. Accomplish the results of the previous exercise using only the after function.

25. 只用 after 函数,完成上一题同样的效果。

26. Bind the e key to expand the circle when pressed and the s to shrink the circle when pressed. Each new key press should cancel any expansion/shrinking that is still happening.

26.e 键绑定为按下时让圆放大,把 s 键绑定为按下时让圆缩小。每一次新的按键触发,都应该取消当前还没完成的放大或缩小过程。

27. Suppose we have the following code:

27. 假设我们有下面这段代码:

lua
function love.load()
    timer = Timer()
    a = 10
end

function love.update(dt)
    timer:update(dt)
end

Using only the tween function and without placing the a variable inside another table, how would you tween its value to 20 over 1 second using the linear tween mode?

只使用 tween,并且不把变量 a 包进其他表里,要怎样在 1 秒内以 linear 模式把它的值 tween 到 20?

Table Functions

Now for the final library I'll go over Yonaba/Moses which contains a bunch of functions to handle tables more easily in Lua. The documentation for it can be found here. By now you should be able to read through it and figure out how to install it and use it yourself.

最后再来看一个库:Yonaba/Moses。它提供了很多能让 Lua 里处理 table 更轻松的函数。文档在这里。学到这里,你应该已经可以自己去看文档,并弄明白它该怎么安装、怎么用了。

But before going straight to exercises you should know how to print a table to the console and verify its values:

不过在直接开始做题之前,你最好先知道怎么把一张表打印到控制台,方便检查里面的值:

lua
for k, v in pairs(some_table) do
    print(k, v)
end

Table Exercises

For all exercises assume you have the following tables defined:

下面所有练习都默认你已经定义了这些表:

lua
a = {1, 2, '3', 4, '5', 6, 7, true, 9, 10, 11, a = 1, b = 2, c = 3, {1, 2, 3}}
b = {1, 1, 3, 4, 5, 6, 7, false}
c = {'1', '2', '3', 4, 5, 6}
d = {1, 4, 3, 4, 5, 6}

You are also required to use only one function from the library per exercise unless explicitly told otherwise.

另外,除非题目特别说明,否则每一道题都只能使用库里的一个函数。

28. Print the contents of the a table to the console using the each function.

28. 使用 each 函数,把 a 表的内容打印到控制台。

29. Count the number of 1 values inside the b table.

29. 统计 b 表中值为 1 的元素有几个。

30. Add 1 to all the values of the d table using the map function.

30. 使用 map 函数,把 d 表里所有值都加 1。

31. Using the map function, apply the following transformations to the a table: if the value is a number, it should be doubled; if the value is a string, it should have 'xD' concatenated to it; if the value is a boolean, it should have its value flipped; and finally, if the value is a table it should be omitted.

31. 使用 map 函数,对 a 表执行以下转换:如果值是数字,就把它翻倍;如果值是字符串,就在末尾拼接 'xD';如果值是布尔值,就把真假取反;如果值本身又是一张表,就把它忽略掉。

32. Sum all the values of the d list. The result should be 23.

32.d 列表中的所有值加起来,结果应该是 23。

33. Suppose you have the following code:

33. 假设你有下面这段代码:

lua
if _______ then
    print('table contains the value 9')
end

Which function from the library should be used in the underscored spot to verify if the b table contains or doesn't contain the value 9?

横线的位置该用库里的哪个函数,才能判断 b 表中是否包含值 9?

34. Find the first index in which the value 7 is found in the c table.

34. 找出 c 表里值为 7 第一次出现时的索引。

35. Filter the d table so that only numbers lower than 5 remain.

35. 过滤 d 表,只保留小于 5 的数字。

36. Filter the c table so that only strings remain.

36. 过滤 c 表,只保留字符串。

37. Check if all values of the c and d tables are numbers or not. It should return false for the first and true for the second.

37. 检查 cd 两张表里的所有值是否全都是数字。对 c 应该返回 false,对 d 应该返回 true

38. Shuffle the d table randomly.

38. 随机打乱 d 表。

39. Reverse the d table.

39.d 表反转过来。

40. Remove all occurrences of the values 1 and 4 from the d table.

40.d 表里移除所有值为 1 和 4 的元素。

41. Create a combination of the b, c and d tables that doesn't have any duplicates.

41.bcd 三张表合并成一张没有重复项的新表。

42. Find the common values between b and d tables.

42. 找出 bd 两张表中的共同值。

43. Append the b table to the d table.

43.b 表追加到 d 表后面。