第一章 游戏循环
原文: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()
end
function love.update(dt)
end
function love.draw()
end
如果通过 LÖVE 运行它,应该会弹出一个黑色背景的窗口。在上面的代码中,一旦开始运行,love.load
只会在程序开始时执行一次,而love.update
和love.draw
每帧都会执行。因此,如果你想加载图像并绘制它到屏幕上,可以执行下面的操作:
function love.load()
image = love.graphics.newImage('image.png')
end
function love.update(dt)
end
function 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 then
love.math.setRandomSeed(os.time())
end
if 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() end
local dt = 0
-- Main loop time.
-- 主循环
while true do
-- Process events.
-- 处理事件
if love.event then
love.event.pump()
for name, a,b,c,d,e,f in love.event.poll() do
if name == "quit" then
if not love.quit or not love.quit() then
return a
end
end
love.handlers[name](a,b,c,d,e,f)
end
end
-- Update dt, as we'll be passing it to update
-- 更新 deltaTime,我们将会把它作为参数传递给 update 函数
if love.timer then
love.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
if love.graphics and love.graphics.isActive() then
love.graphics.clear(love.graphics.getBackgroundColor())
love.graphics.origin()
if love.draw then love.draw() end
love.graphics.present()
end
if love.timer then love.timer.sleep(0.001) end
end
end
程序启动时,会运行love.run
,之前提到的那些方法都在love.run
中被调用。该函数的注释相当清晰,你可以在 LÖVE wiki 上找到每个函数对应的功能。不过我也会介绍一些基础内容:
if love.math then
love.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() end
local 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 then
love.event.pump()
for name, a,b,c,d,e,f in love.event.poll() do
if name == "quit" then
if not love.quit or not love.quit() then
return a
end
end
love.handlers[name](a,b,c,d,e,f)
end
end
end
这里是游戏主循环开始的地方。每一帧要做的第一件事是事件的处理。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 then
love.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() then
love.graphics.clear(love.graphics.getBackgroundColor())
love.graphics.origin()
if love.draw then love.draw() end
love.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
循环。