第一章 游戏循环
原文:https://github.com/a327ex/blog/issues/15
目录
开始
首先,你需要在系统上安装 LÖVE,然后弄清楚如何运行 LÖVE 项目。我们将使用的 LÖVE 版本是0.10.2,可以在这里下载。如果未来 LÖVE 新版本发布,你仍然可以从这里下载 0.10.2。你可以按照此页面上的步骤进行操作以获取更多详细信息。完成后,您需要在项目文件夹中创建一个main.lua文件,其内容如下:
function love.load()endfunction love.update(dt)endfunction love.draw()end
如果通过 LÖVE 运行它,应该会弹出一个黑色背景的窗口。在上面的代码中,一旦开始运行,love.load只会在程序开始时执行一次,而love.update和love.draw每帧都会执行。因此,如果你想加载图像并绘制它到屏幕上,可以执行下面的操作:
function love.load()image = love.graphics.newImage('image.png')endfunction love.update(dt)endfunction love.draw()love.graphics.draw(image, 0, 0)end
love.graphics.newImage加载图像纹理并赋值给image变量,然后每帧被渲染到 (0, 0) 的位置。为了查看love.draw每一帧实际绘制的内容,请尝试以下操作:
love.graphics.draw(image, love.math.random(0, 800), love.math.random(0, 600))
窗口的默认大小为 800x600,因此上述操作是在屏幕上随机位置绘制图像:

请注意,在每一帧之间屏幕都将被清空,否则,随机位置绘制的图像将逐步填充满整个屏幕。这是因为 LÖVE 为项目提供了默认的游戏循环,该循环会在每一帧结束时清空屏幕。接下来,我将介绍这个游戏循环,以及如何立马上手修改它。
游戏循环
LÖVE 使用的默认游戏循环可以在love.run页面中找到,如下所示:
function love.run()if love.math thenlove.math.setRandomSeed(os.time())endif love.load then love.load(arg) end-- We don't want the first frame's dt to include time taken by love.load.-- 我们不希望第一帧时 deltaTime 包含加载耗时if love.timer then love.timer.step() endlocal dt = 0-- Main loop time.-- 主循环while true do-- Process events.-- 处理事件if love.event thenlove.event.pump()for name, a,b,c,d,e,f in love.event.poll() doif name == "quit" thenif not love.quit or not love.quit() thenreturn aendendlove.handlers[name](a,b,c,d,e,f)endend-- Update dt, as we'll be passing it to update-- 更新 deltaTime,我们将会把它作为参数传递给 update 函数if love.timer thenlove.timer.step()dt = love.timer.getDelta()end-- Call update and draw-- 调用 update 和 draw 函数if love.update then love.update(dt) end -- will pass 0 if love.timer is disabled -- 如果 love.timer 模块被禁用,则 dt 为 0if love.graphics and love.graphics.isActive() thenlove.graphics.clear(love.graphics.getBackgroundColor())love.graphics.origin()if love.draw then love.draw() endlove.graphics.present()endif love.timer then love.timer.sleep(0.001) endendend
程序启动时,会运行love.run,之前提到的那些方法都在love.run中被调用。该函数的注释相当清晰,你可以在 LÖVE wiki 上找到每个函数对应的功能。不过我也会介绍一些基础内容:
if love.math thenlove.math.setRandomSeed(os.time())end
代码第一行检查love.math是否不为nil。在 Lua 中除了false和nil,其他都是真值,因此,如果love.math在其他地方被定义过,那么if love.math条件将会为true。对于 LÖVE,这些变量在conf.lua文件中设置是否启用。你现在不需要考虑这个文件,我在这里提及它,是因为在 LÖVE 中你可以启用或禁用各个独立系统模块,如love.math,这也是为什么,在使用诸如love.math这样的系统模块时,要首先检查下它是否被启用。
通常在 Lua 中,如果你以任何方式访问一个未定义的变量,你将得到nil值。因此,如果你在定义前(如random_variable = 1),进行if random_variable判断,你将得到false。
总而言之,如果love.math模块被启用(默认如此),那么它会基于当前时间设置一个随机数种子。参见[love.math.setRandomSeed](https://love2d.org/wiki/love.math.setRandomSeed)和[os.time](https://www.lua.org/pil/22.1.html)。执行完这些操作后,接着会调用love.load函数:
if love.load then love.load(arg) end
arg是运行项目时传递给 LÖVE 可执行文件的命令行参数。如你所见,之所以love.load只会被调用一次,是因为它在代码里值被调用了一次,而update和draw函数在一个循环里不断地调用(该循环的每一次执行都对应游戏里的一帧)。
-- We don't want the first frame's dt to include time taken by love.load.-- 我们不希望第一帧时 deltaTime 包含加载耗时if love.timer then love.timer.step() endlocal dt = 0
在调用love.load且它的逻辑都执行完后,我们检查love.timer是否被启用,如果被启用则调用love.timer.step方法,该方法将计算最后两帧之间的时间差。正如注释所提到的那样,love.load可能需要很长时间才能执行完毕(因为它可能会加载图像和音频等各类资源),并且这个加载时间不应该包含在第一次调用love.timer.getDelta()时,即第一帧的deltaTime中。
dt也在这里初始化为 0。在 Lua 中,默认情况下变量都是全局变量,这里通过local关键字将dt定义为只在当前代码块(即love.run函数体)生效的局部变量。关于作用域的更多信息可以参阅这里。
-- Main loop time.-- 主循环while true do-- Process events.-- 处理事件if love.event thenlove.event.pump()for name, a,b,c,d,e,f in love.event.poll() doif name == "quit" thenif not love.quit or not love.quit() thenreturn aendendlove.handlers[name](a,b,c,d,e,f)endendend
这里是游戏主循环开始的地方。每一帧要做的第一件事是事件的处理。love.event.pump将事件推送到事件队列,根据用户不同的操作产生描述不同的事件,想象一下,按下键盘、鼠标点击、改变窗口尺寸、窗口焦点丢失/获得等。然后循环通过love.event.poll遍历事件队列并处理每一个事件。love.handlers是一个包含相关回调函数的 table。因此,如果love.handlers.quit存在,那么它将会调用love.quit函数。
对于 LÖVE 来说,你可以在main.lua中定义各种回调函数,当事件发生时,对应的回调函数就会被调用。这里提供了所有回调的完整列表。稍后我会更详细地讨论这些回调,不过目前我们已经知道了这些回调是怎么被调用的。其中传递给love.handlers[name]回调函数的a, b, c, d, e, f参数,是所有相关回调函数可能用到的参数。举个例子,love.keypressed接受按下的键、其扫描码和是否重复三个参数,因此,a, b, c将被定义为对应的值,而d, e, f则为nil。
-- Update dt, as we'll be passing it to update-- 更新 deltaTime,我们将会把它作为参数传递给 update 函数if love.timer thenlove.timer.step()dt = love.timer.getDelta()end-- Call update and draw-- 调用 update 和 draw 函数if love.update then love.update(dt) end -- will pass 0 if love.timer is disabled -- 如果 love.timer 模块被禁用,则 dt 为 0
love.timer.step计算最后两帧之间的时间差,并更改love.timer.getDelta函数的返回值。因此,dt将包含最后一帧运行所花费的时间。这非常有用,因为这个值会被传递给love.update函数,通过它游戏可以在帧率发生变化的情况下仍然为匀速移动的物体计算其位移等。
if love.graphics and love.graphics.isActive() thenlove.graphics.clear(love.graphics.getBackgroundColor())love.graphics.origin()if love.draw then love.draw() endlove.graphics.present()end
调用完love.update后,接着将调用love.draw。不过在那之前,我们还是要先验证下love.graphics系统模块是否启用,并且只有当love.graphics.isActive()为true时,我们才可以将内容绘制到屏幕上。通过love.graphics.clear屏幕将被清空成背景颜色(默认为黑色),通过love.graphics.origin将已产生的各种变换重置,最终调用love.draw函数,然后通过[love.graphics.present]将所有通过love.draw绘制的内容绘制到屏幕上。接着也是主循环的最终部分:
if love.timer then love.timer.sleep(0.001) end
我一直不理解每帧的最后为什么要调用一下love.timer.sleep,不过 LÖVE 开发人员在这里给出的解释似乎很合理。
至此,整个love.run函数便结束了。while true循环中执行的所有内容对应一帧,这就意味着love.update和love.draw每帧都会被调用。整个游戏基本上都在以非常快的速度(例如每秒60帧)重复执行循环里的内容,因此请适应这些内容。我记得当我刚开始的时候,花费了不少时间才能本能地理解这些是如何工作的。
如果你想了解更多有关这个函数的信息,LÖVE forums有一个很有帮助的讨论。
总之,如果你不想这么做,你也可以不必一开始就了解所有这些内容,不过弄懂它可以在一定程度上使你更轻松编写出想要的游戏循环方式。这里有一篇很棒的文章,它介绍了不同的游戏循环技术,并很好地解释了每种技术,你可以在这里找到它。
游戏循环练习
在游戏循环中,垂直同步(Vsync)扮演什么样的角色?重置同步默认开启,你可以通过
love.window.setMode带上vsync参数来关闭它。根据《Fix Your Timestep》文章的内容,通过修改
love.run实现Fixed Delta Time循环。根据《Fix Your Timestep》文章的内容,通过修改
love.run实现Variable Delta Time循环。根据《Fix Your Timestep》文章的内容,通过修改
love.run实现Semi-Fixed Timestep循环。根据《Fix Your Timestep》文章的内容,通过修改
love.run实现Free the Physics循环。
