第二章 库

原文:https://github.com/a327ex/blog/issues/16

目录

简介

在本文中,我们将介绍本项目所需的一些 Lua/LÖVE 库,并且还将探讨一些 Lua 特有的做法。从现在开始你应该慢慢熟悉它们。到本文结束,这里一共将介绍 4 个库,不仅如此,本文还希望使你熟悉下载其他人编写构建的库,阅读这些库的文档,并弄清楚它们的工作原理以及如何在我们的游戏中使用它们。Lua 和 LÖVE 本身并没有提供很多功能,因此下载他人编写的代码并使用它是非常常见且必要的事情。

面向对象

我将介绍的第一件事是面向对象。在 Lua 中模拟面向对象的方法有很多种,不过我会选择简单地使用一个库。我最喜欢的 OOP 库是 rxi/classic,因为它很小且有效。要安装它,只需要下载它并将classic文件夹放到项目目录下。通常,我会创建一个名为libraries的目录,并将所有的库文件放到该目录下。

译注:面向对象程序设计(英语:Object-oriented programming,缩写:OOP)是种具有对象概念的程序编程典范,同时也是一种程序开发的抽象方针。它可能包含资料、属性、代码与方法。对象则指的是类的实例。它将对象作为程序的基本单元,将程序和数据封装其中,以提高软件的重用性、灵活性和扩展性,对象里的程序可以访问及经常修改对象相关连的资料。在面向对象程序编程里,计算机程序会被设计成彼此相关的对象。 ————维基百科

一旦你完成了上述操作,你可以在main.lua中加入以下代码来将库导入到游戏中:

  1. Object = require 'libraries/classic/classic'

如同它 Github 页面描述的那样,你可以使用该库达到所有 OOP 概念中常见的效果。我一般会将创建的新类放置在一个单独的文件中,并将其放置在objects目录下。例如,创建一个Test类,并将其实例化一次的代码如下所示:

  1. -- in objects/Test.lua
  2. Test = Object:extend()
  3. function Test:new()
  4. end
  5. function Test:update(dt)
  6. end
  7. function Test:draw()
  8. end
  1. -- in main.lua
  2. Object = require 'libraries/classic/classic'
  3. require 'objects/Test'
  4. function love.load()
  5. test_instance = Test()
  6. end

当在main.lua中调用require 'objects/Test'时,将执行Test.lua中定义的所有内容,这意味着Test这个全局变量现在包含了 Test 类的定义。对于该游戏来说,每个类定义都将如此,这意味着类名必须唯一,因为它们将绑定到对应的全局变量上。如果你不想这样做,你也可以进行以下更改:

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

通过在Test.lua文件中定义一个Test局部变量,该类定义不会再直接绑定到对应的全局变量上,这意味着你可以在main.lua中将其绑定到任意你希望的变量上。在Test.lua脚本的末尾,将局部变量返回,因此在main.lua里,当Test = require 'objects/Test'执行时,Test 类定义会被赋值给全局变量Test上。

有时,例如你在为其他人编写库时,这是一种更好的处理方式,因为你不会用库里的变量来污染他们项目中的全局变量。这也是classic这个库所做的,同时解释了为什么你需要通过将其赋值给Object对象来对其进行初始化。这样做的一个好处是,因为我们将库赋值给一个变量,如果你愿意,你可以将Object重命名为Class,然后你的类定义将类似于Test = Class:extend()

我要做的最后一件事是使所有类的require过程自动化。为了将一个类添加到环境中来,你需要输入require 'objects/ClassName这么一长串代码。这样做的问题是当类很多时,我们需要写很长一串require代码,而且每次新增一个类也需要手动新增require代码。因此,可以执行以下操作来自动化该过程:

  1. function love.load()
  2. local object_files = {}
  3. recursiveEnumerate('objects', object_files)
  4. end
  5. function recursiveEnumerate(folder, file_list)
  6. local items = love.filesystem.getDirectoryItems(folder)
  7. for _, item in ipairs(items) do
  8. local file = folder .. '/' .. item
  9. if love.filesystem.isFile(file) then
  10. table.insert(file_list, file)
  11. elseif love.filesystem.isDirectory(file) then
  12. recursiveEnumerate(file, file_list)
  13. end
  14. end
  15. end

让我们来分解一下上述代码。recursiveEnumerate函数递归罗列给定目录下所有文件,并将它们以字符串的形式添加到一个表中。它利用了 LÖVE 文件系统模块,该模块包含了许多有用的功能。

循环中的第一行列出了给定目录中的所有文件和文件夹,并使用love.filesystem.getDirectoryItems将它们作为字符串表返回。接下来,遍历这些字符串,并通过将目录路径和这些字符串连接起来(在 Lua 中使用..进行字符串拼接)获得它们的完整路径。

假设目录路径是objects,并且在该路径下只有一个名为GameObject.lua的文件。那么,items列表内容看起来就会是这样items = {'GameObject.lua'}。当遍历该列表时,local file = folder .. '/' .. item这一行代码将会被解释成local file = 'objects/GameObject.lua',这就是该文件的完整路径。

然后,使用love.filesystem.isFilelove.filesystem.isDirectory函数可以检查该完整路径是文件还是目录。如果是文件的话,只需要将其添加到从调用方传入的file_list表中,否则再次调用recursiveEnumerate,不过这次使用新的目录路径作为参数。当一切完成后,file_list表中将包含指定目录下的所有文件的路径。在我们的例子中,object_files变量将是是一个包含objects目录下所有类文件路径的字符串表。

不过我们还剩下一步,那就是遍历所有路径并require它们:

  1. function love.load()
  2. local object_files = {}
  3. recursiveEnumerate('objects', object_files)
  4. requireFiles(object_files)
  5. end
  6. function requireFiles(files)
  7. for _, file in ipairs(files) do
  8. local file = file:sub(1, -5)
  9. require(file)
  10. end
  11. end

代码非常直接,它只是简单遍历所有文件,并对其调用require。剩下唯一要做的事就是从字符串尾部删除多余的.lua,因为如果不删掉并调用require的话,会报错。执行该操作的是local file = file:sub(1, -5)这一行代码,它使用了 Lua 内置的[字符串函数]。完成这些操作后,便可以自动将定义在objects目录下的所有类加载到环境中。recursiveEnumerate函数还将在以后用于自动加载其他资源,诸如图像、音频、着色器等。

OOP 练习

  1. 创建一个Circle类,在其构造函数中接收xyradius参数,它拥有xyradiuscreation_time属性,并具有updatedraw方法。xyradius应初始化为从构造函数传入的值,creation_time应初始化为创建实例时的相对时间(参见love.timer)。update方法应接受一个dt作为参数,而draw函数应在xy位置处绘制一个半径为radius的白色实心圆(参见love.graphics)。该Circle类的一个实例被创建在 400, 300 位置处,半径(radius)为 50。它应该被更新并绘制在屏幕上,最终屏幕上应该显示成如下效果:

    1

  2. 创建一个HyperCircle类继承自Circle类。一个HyperCircle很像一个Circle,只不过它还有一圈圆环。它应该在构造函数中额外接受line_widthoutside_radius参数。该HyperCircle类的一个实例被创建在 400, 300 位置处,半径为 50,线宽(line_width)为 10,外圈半径(outside_radius)为 120。屏幕应显示成如下效果:

    2

  3. 思考:Lua 中:操作符的作用是什么?它与.操作符有何不同,什么时候应该使用它?

  4. 假设我们有以下代码:

    1. function createCounterTable()
    2. return {
    3. value = 1,
    4. increment = function(self) self.value = self.value + 1 end,
    5. }
    6. end
    7. function love.load()
    8. counter_table = createCounterTable()
    9. counter_table:increment()
    10. end

    思考:counter_table.value的值是多少?为什么increment函数接受一个名为self的参数?这个参数可以命名为其他名字吗?在这个例子中,self表示什么?

  5. 创建一个函数,该函数返回一个包含属性abcsum的表。其中abc应初始化为123,且sum应是将abc相加在一起的函数。累加的最终结果应存储在表中的c字段里(即完成所有操作后,表中应该有个字段c,它的值为 6)。

  6. 思考:如果一个类拥有名为someMethod的方法,那么它还可以拥有名为someMethod的属性吗?如果不可以,为什么?

  7. 思考:在 Lua 中,什么是全局表?

  8. 基于我们上述的类自动加载方式,每当需要一个类从另一个类继承时,我们需要写下面这样的代码:

    1. SomeClass = ParentClass:extend()

    思考:是否可以保证在执行到这一行时已经定义了ParentClass变量?或者换句话说,是否可以保证在定义SomeClass时,ParentClass已经被require?如果可以保证,那么是什么来保证呢?如果不可以,那么怎么解决该问题呢?

  9. 假设所有类文件都不是全局定义的,而是本地定义的,例如:

    1. local ClassName = Object:extend()
    2. ...
    3. return ClassName

    思考:如何更改requireFiles函数,以便我们仍可以自动加载所有类?

输入

现在介绍如何处理输入。在 LÖVE 中默认方法是通过一些回调来处理输入。当这些回调函数被定义后,只要相关的事件发生,LÖVE 就会调用这些函数,你可以在这里添加你的逻辑来达到你想要的效果:

  1. function love.load()
  2. end
  3. function love.update(dt)
  4. end
  5. function love.draw()
  6. end
  7. function love.keypressed(key)
  8. print(key)
  9. end
  10. function love.keyreleased(key)
  11. print(key)
  12. end
  13. function love.mousepressed(x, y, button)
  14. print(x, y, button)
  15. end
  16. function love.mousereleased(x, y, button)
  17. print(x, y, button)
  18. end

在上面这种情况下,每当你按一个按键或点击屏幕上的任何位置时,相关信息都将打印到控制台。在我使用过程中,觉得这种处理方式一致存在一个很大的问题:它强迫你在任何需要接受这些事件的代码中加上对应的方法。

假设你有一个game对象,它其中包含一个level对象,level对象其中又包含一个player对象。要使得player对象接收到键盘输入,上述 3 种对象都必须定义两个与键盘相关的回调函数(按下与释放)。因为在最上层,你只想在love.keypressed中调用game:keypressed,你不希望低一些层级的对象知道levelplayer的存在。因此,为了解决这一问题,我创建了一个。你可以像使用其他库一样下载和安装它。以下是一个展示它如何工作的例子:

  1. function love.load()
  2. input = Input()
  3. input:bind('mouse1', 'test')
  4. end
  5. function love.update(dt)
  6. if input:pressed('test') then print('pressed') end
  7. if input:released('test') then print('released') end
  8. if input:down('test') then print('down') end
  9. end

库所做的是不在依赖回调函数进行事件响应,而是简单查询当前帧对应的按键是否被按下。在上面的例子中,当你按下mouse1那帧,将打印pressed到控制台,当你抬起mouse1那帧,将打印released到控制台。而那些你没有按下mouse1的帧,input:pressedinput:released将返回false,判断内部的逻辑也不会执行。input:down函数也是如此,对应按钮按下的帧返回true,其余帧返回false

通常,你希望逻辑是在持续按下某键时以一定的时间间隔重复执行,而不是每一帧都执行,那么,你可以使用下面的功能:

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

在此示例中,一旦按下绑定到test行为的键,则每个 0.5 秒会将test event打印到控制台。

输入练习

  1. 假设我们有以下代码:

    1. function love.load()
    2. input = Input()
    3. input:bind('mouse1', function() print(love.math.random()) end)
    4. end

    思考:当按下mouse1键时会发生什么事情?当抬起mouse1键时又会发生什么?按住时呢?

  2. 绑定小键盘+键到add行为上,然后在按住add行为对应的键时,每 0.25 秒将名为sum(从 0 开始)变量值加 1。每次增加时将其值打印到控制台。

  3. 思考:可以将多个按键绑定到同一个行为上吗?如果不可以,为什么?可以将同一个按键绑定到多个不同的行为上吗?如果不可以,为什么?

  4. 如果你有一个游戏手柄,绑定它的上下左右按键到upleftrightdown行为上,在它们按下的时候将动作名称打印到控制台。

  5. 如果你有一个游戏手柄,绑定其中一个扳机键(L2, R2)到trigger行为上。按下扳机键将返回 0 到 1 的值,而不是是否按下的布尔值。你将如何获取这个值?

  6. 重复与上一个练习相同的操作,不过这次是对左右摇杆的水平和垂直操作进行练习。

计时器

现在,另一个重要的代码段是通用计时器功能。为此我将使用 hump,更准确地说是 hump.timer

  1. Timer = require 'libraries/hump/timer'
  2. function love.load()
  3. timer = Timer()
  4. end
  5. function love.update(dt)
  6. timer:update(dt)
  7. end

根据官方文档,可以直接通过全局Timer变量使用它,也可以将其实例化为新的变量。我决定使用后者。我将使用timer这个全局变量来作为全局计时器,其余对象内部需要计时器时(例如Player类中),我将本地化它们自己的计时器。

在整个游戏中使用到的最重要的函数是aftereverytween。值得一提的是,虽然我个人不使用script函数,不过有些人可能觉得它非常有用。接着,让我们来依次浏览下这些函数:

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

after非常直接,它接受一个数字和一个函数,并在数秒之后执行该函数。在上面的示例中,游戏运行 2 秒后将在控制台上打印一个随机数。after还有很多非常酷的特性,其中之一就是可以将多个after串在一起,例如:

  1. function love.load()
  2. timer = Timer()
  3. timer:after(2, function()
  4. print(love.math.random())
  5. timer:after(1, function()
  6. print(love.math.random())
  7. timer:after(1, function()
  8. print(love.math.random())
  9. end)
  10. end)
  11. end)
  12. end

在此示例中,将在游戏开始 2 秒后打印一个随机数到控制台,接着再延迟 1 秒后打印另一个随机数(即开始 3 秒之后),最后再过 1 秒(即开始 4 秒之后)打印最后一个随机数。这在某种程度上类似于script功能,你可以任意选择你最喜欢的方式。

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

在上面这个示例中,每一秒将会打印一个随机数到控制台上。类似after函数,它也接受一个数字和一个函数作为参数,并在数秒后开始执行函数内容。它还可以接受一个数字为第三个参数,该参数表示指定的函数将会被调用几次。

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

上面的写法只会在前 5 秒内每隔 1 秒打印一个随机数字。还有一种可以使every函数停下来而不需要特别指定运行次数的方式是使指定函数的返回值为false。这对于那些调用次数不固定,需要动态判断的情景非常适用。

还可以通过after函数来达到every函数的效果,如下所示:

  1. function love.load()
  2. timer = Timer()
  3. timer:after(1, function(f)
  4. print(love.math.random())
  5. timer:after(1, f)
  6. end)
  7. end

我从来没有研究过它内部具体是如何实现的,但是库的创建者决定允许这样写并且在文档中有说明,那么我便愉快地使用它^ ^。以这种方式来达到every函数的效果也有其有用之处,我们可以通过更改第二个after调用中的延迟时间参数,来达到更改两次调用之间的时间间隔的效果。

  1. function love.load()
  2. timer = Timer()
  3. timer:after(1, function(f)
  4. print(love.math.random())
  5. timer:after(love.math.random(), f)
  6. end)
  7. end

如上所述,在上面这个例子中,每次调用的时间间隔是一个变化量(0 到 1 之间,因为love.math.random默认返回该范围内的值),这是every函数默认所无法实现的功能。变化的时间间隔在许多情况下非常有用,因此你最好知道怎么实现。接下来,介绍tween函数:

  1. function love.load()
  2. timer = Timer()
  3. circle = {radius = 24}
  4. timer:tween(6, circle, {radius = 96}, 'in-out-cubic')
  5. end
  6. function love.update(dt)
  7. timer:update(dt)
  8. end
  9. function love.draw()
  10. love.graphics.circle('fill', 400, 300, circle.radius)
  11. end

tween是其中最难使用的函数,因为它的参数很多,其中包括花费的时间,初始属性表,目标属性表和插值模式。然后它会将初始属性向着目标属性进行插值。因此,在上面的示例中,表circle有一个属性radius初始值为 24。在 6 秒的内,它将以in-out-cubic插值模式变化至 96。(这里是实用插值模式列表)。这听起来很复杂,但实际看起来像这样:

3

tween函数在插值模式参数后面还可以额外传递一个函数,当插值结束时,就会调用该函数。这可以达到多种效果,不过如果以前面的例子为例的话,我们可以在圆扩大完之后再将其缩回去:

  1. function love.load()
  2. timer = Timer()
  3. circle = {radius = 24}
  4. timer:after(2, function()
  5. timer:tween(6, circle, {radius = 96}, 'in-out-cubic', function()
  6. timer:tween(6, circle, {radius = 24}, 'in-out-cubic')
  7. end)
  8. end)
  9. end

看起来就像这样:

4

在我的使用习惯中,aftereverytween这三个函数是最实用的。它们用途广泛,可以实现多种效果。因此,请确保你理解了它们的工作方式。

关于这个计时器库还有一件重要的事是,这些函数调用都会返回一个句柄(handle)。该句柄可以与cancel函数配合使用,用于中止特定的计时器:

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

在上述示例中,我们首先调用after函数,它将在 2 秒后打印一个随机值到控制台,并且我们将其返回的句柄存放到handle_1变量中。接着,我们调用cancel函数,将handle_1作为参数传递,将对应的逻辑取消。能达到这样的效果是非常重要的,因为通常我们会遇到一种情况————根据某些特定事件创建定时器。比如,当有人按下r键时,我们希望在 2 秒后打印一个随机数字到控制台:

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

如果你将上面的代码添加到mian.lua,并运行整个项目,每当你按下r键,过 2 秒就会在控制台显示一个随机数字。如果你反复按下r键多次,控制台也会出现多个数字,但是它们是快速连续出现。但有时我们又希望,如果事件重复出现多次,应该重置我们的计时器。这意味着,当我按下r键时,我们希望取消所有前面因为该事件所创建的延迟行为。一种实现方法是以某种方式存储所有被创建的句柄,并将它们与一个事件标识符绑定,然后在事件标识符上调用某些cancel函数,这将取消所有与该事件标识符关联的计时器句柄。该解决方案看起来如下所示:

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

我创建了当前计时器模块的增强版本,它支持添加事件标签。在这种情况下,事件标签r_key_press会被附加到按下r键时创建的计时器上,如果重复按下该键,模块将自动取消先前创建出来的计时器,这也是我们想要的默认行为。如果未使用事件标签,则该模块与之前介绍的行为一样。

你可以在此处下载此增强版本,并将main.lua中导入的libraries/hump/timer换成你最终存放EnhancedTimer.lua的位置,我个人将其放置在libraries/enhanced_timer/EnhancedTimer。这假定了hump库也放置在libraries目录下。如果你将库放置在不同的路劲,这里对应的路径也需要修改。此外,你还可以使用我编写的这个库,该库具有与hump.timer相同的功能,但也可以按照上面描述处理事件标签。

计时器练习

  1. 只使用for循环以及在循环内使用一个after函数,打印 10 个随机数到屏幕上,每两个随机数之间间隔 0.5 秒。

  2. 假设我们有以下代码:

    1. function love.load()
    2. timer = Timer()
    3. rect_1 = {x = 400, y = 300, w = 50, h = 200}
    4. rect_2 = {x = 400, y = 300, w = 200, h = 50}
    5. end
    6. function love.update(dt)
    7. timer:update(dt)
    8. end
    9. function love.draw()
    10. love.graphics.rectangle('fill', rect_1.x - rect_1.w/2, rect_1.y - rect_1.h/2, rect_1.w, rect_1.h)
    11. love.graphics.rectangle('fill', rect_2.x - rect_2.w/2, rect_2.y - rect_2.h/2, rect_2.w, rect_2.h)
    12. end

    只使用tween函数,使用in-out-cubic插值模式持续 1 秒对第一个矩形的w属性进行插值。完成此操作后,使用in-out-cubic插值模式持续 1 秒对第二个矩形的h属性进行插值。之后,使用in-out-cubic插值模式持续 2 秒对两个矩形进行插值,使它们的属性回到初值。这看起来应该像下面这样:

    5

  3. 对于此练习,你要创建一个 HP 条。每当用户按下d键时,HP 条应模拟受到伤害的效果。这看起来应该像下来这样:

    6

    如你所见,此 HP 条有两层。每当受到伤害时,上层缩减的速度更快,底层会滞后一会再缩减。

  4. 以前面的扩大和收缩圆为例,它先扩大一次,然后收缩一次。思考:如何修改代码使其可以永远重复扩大和收缩下去?

  5. 思考:如何仅使用after函数完成上一个练习。

  6. 按下e键时扩大圆,按下s键时收缩圆。每次新的按键按下时,都应取消所有仍在生效的扩大/收缩行为。

  7. 假设我们有以下代码:

    1. function love.load()
    2. timer = Timer()
    3. a = 10
    4. end
    5. function love.update(dt)
    6. timer:update(dt)
    7. end

    思考:仅使用tween函数,而没有将变量放在另一个表中,如果通过linear插值模式在 1 秒内将其插值到 20?

表相关函数

终于轮到我们最后一个库了,我将介绍Yonaba/Moses,它包含了一堆可以更轻松地处理表的函数。它的文档可以在这里找到。到目前为止,你应该有能力独自阅读它,并弄清楚如何安装和使用它。

在继续练习之前,你应该知道如何将表打印到控制台,验证其值是否正确:

  1. for k, v in pairs(some_table) do
  2. print(k, v)
  3. end

表练习

对于下面所有练习,都假设定义了以下表格:

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

除非另有明确说明,否则每个练习仅需要使用库中的一个功能。

  1. 使用each函数将表a的内容打印至控制台。

  2. 计算表b中值为 1 的元素的数量。

  3. 使用map函数,将表d中所有元素加 1。

  4. 使用map函数,对表a应用如下转换:如果元素是数字,则将其翻倍;如果元素是字符串,则将其与xD相连;如果元素是布尔值,则将其翻转(取反);如果元素是一个表,则不对其进行任何操作。

  5. 计算表d所有元素之和,其结果应为 23。

  6. 假设你有以下代码:

    1. if _______ then
    2. print('table contains the value 9')
    3. end

    应该在下划线处使用库中的哪个函数来验证表b是否包含值为 9 的元素。

  7. 查找表c中值为 7 的第一个元素的位置。

  8. 筛选出表d中小于 5 的元素。

  9. 筛选出表c中类型为字符串的元素。

  10. 检查表c和表d中的元素是否都是数字。其结果应该为:表cfalse,表dtrue

  11. 随机打乱表d

  12. 翻转表d

  13. 从表d中删除所有值为 1 和值为 4 的元素。

  14. 创建一个不包含重复元素的表b、表c、表d的组合。

  15. 查找表b和表d之间的公共元素。

  16. 将表b附加到表d之后。


上一章 游戏循环

下一章 房间和区域概念