第五章 游戏基础

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

目录

简介

在这一章中,我们将开始介绍游戏本身的逻辑。首先,我们会从游戏玩法的角度来概述整个游戏的结构,接着我们将重点介绍贯穿整个游戏各部分的通用基础内容,例如像素化外观、摄像头,以及物理模拟等。之后,我们将介绍玩家的移动操作,最后,我们将研究垃圾回收以及如何注意到可能的内存泄漏。

游戏框架

游戏本身只会被分为 3 个不同的房间:StageConsoleSkillTree

Stage房间是游戏玩法真正体现的地方(即玩家游玩的场景),它将具有诸如玩家、敌人、弹药、资源、道具等。游戏玩法与 Bit Blaster XL 非常相似,实际上也相当简单。我之所以选择这种简单的玩法,是因为简单的玩法可以让我更加专注于开发游戏的其他方面(巨大的技能树)。

1

Console房间是一个类似“菜单”的地方,在这里可以更改视频、音频设置,查看成就,选择要玩的飞船,访问技能树等等。这里模拟了一个终端,而不是采用传统的菜单选项,对于具有“计算机观感”的游戏(也称为“懒惰的程序员艺术” xD)来说,这样做显得更有意义。并且这个创意是你(玩家)只是通过某地的终端设备进行游戏。

2

SkillTree房间是可以获取所有被动技能的地方。在Stage房间中你可以获取随机生成的 SP(skill points),或者击杀敌人也可以获得。之后一旦你死亡,你可以使用这些技能点数购买被动技能。这里是想尝试像 Path of Exile 中的技能树,并且我认为在这方面确实取得了一定的成功。我构造的技能树有 600 - 800 个节点,我认为这已经足够了。

3

我将详细地介绍每个房间的创建,包括技能书中的全部技能。不过,我也强烈建议你尽可能地不要照搬我写的这些内容。我在游戏玩法上做出的许多设计几乎都是我个人偏好,你可能会喜欢一些不同的内容。

例如,抛开庞大的技能树,你可能更加喜欢职业系统,该系统允许更加丰富的组合,像 Tree of Savior 那样。因此,你可以跟随教程学习各种被动技能的实现方法,然后利用它们构建属于你自己的职业系统,而不是像我一样构建技能树系统。

上面所说也仅仅只是举个例子,你可以在方方面面按照自己的想法来实现。我编写这些教程并包含丰富的练习的原因之一就是鼓励大家自己参与学习,而不仅仅跟着照做,我认为这样才能更好地学习。因此。只要你有机会做一些不同的内容,请务必尝试一下。

游戏显示尺寸

让我们从Stage开始,我们想要做的第一件事(对于所有房间都是如此)是要使画面具有低分辨率像素风外观。例如,请看下面这个圆:

第五章 游戏基础 - 图4

然后再看看这个圆:

第五章 游戏基础 - 图5

我想要的效果是第二个。这样的选择纯粹是出于审美和我个人喜好。有很多游戏并没有采用像素风,但仍然通过简单的形状和颜色达到非常好看的效果,比如这个。因此,一个游戏的观感取决于你选择哪种样式以及你可以打磨的程度。但是对于我们这款游戏来说,我将采用像素风。

实现该目标的第一步是定义一个非常小的默认分辨率,最好可以将其直接缩放至目标分辨率1920x1080。对于该游戏,我将采用480x270,因为这是目标分辨率1920x1080除以 4 的值。默认情况下,要将游戏的大小设置为该尺寸,我们需要conf.lua文件,正如我在前面文章中介绍的那样,它是一个配置文件,其中定义了一个 LÖVE 项目相关的所有配置,包括窗口初始分辨率。

最重要的是,在此文件中,我还定义了两个全局变量gwgh,分别对应基本分辨率的宽和高,以及全局变量sxsy,分别对应应用于基础分辨率上的缩放比例。conf.lua文件应该和main.lua放在同一个目录下,它看起来如下所示:

  1. gw = 480
  2. gh = 270
  3. sx = 1
  4. sy = 1
  5. function love.conf(t)
  6. t.identity = nil -- 游戏标识,保存目录的名字(字符串)
  7. t.version = "0.10.2" -- 游戏使用的 LÖVE 的版本号(字符串)
  8. t.console = false -- 是否附加一个 Console(布尔值,仅 Windows 平台)
  9. t.window.title = "BYTEPATH" -- 窗口名称(字符串)
  10. t.window.icon = nil -- 窗口图标路径(字符串)
  11. t.window.width = gw -- 窗口宽度(数字)
  12. t.window.height = gh -- 窗口高度(数字)
  13. t.window.borderless = false -- 窗口是否无边框(布尔值)
  14. t.window.resizable = true -- 窗口是否可以改变大小(布尔值)
  15. t.window.minwidth = 1 -- 窗口宽度最小值,仅当可以改变窗口大小时生效(数字)
  16. t.window.minheight = 1 -- 窗口高度最小值,仅当可以改变窗口大小时生效(数字)
  17. t.window.fullscreen = false -- 是否可以全屏(布尔值)
  18. t.window.fullscreentype = "exclusive" -- 标准全屏还是窗口全屏(字符串)
  19. t.window.vsync = true -- 是否开启垂直同步(布尔值)
  20. t.window.fsaa = 0 -- 多重采样抗锯齿采样数(数字)
  21. t.window.display = 1 -- 目标显示器索引(数字)
  22. t.window.highdpi = false -- 在视网膜屏上是否开启高 dpi 模式(布尔值)
  23. t.window.srgb = false -- 是否启用伽马校正(布尔值)
  24. t.window.x = nil -- 窗口显示位置 x(数字)
  25. t.window.y = nil -- 窗口显示位置 y(数字)
  26. t.modules.audio = true -- 是否开启音频模块(布尔值)
  27. t.modules.event = true -- 是否开启事件模块(布尔值)
  28. t.modules.graphics = true -- 是否开启图形模块(布尔值)
  29. t.modules.image = true -- 是否开启图片模块(布尔值)
  30. t.modules.joystick = true -- 是否开启摇杆模块(布尔值)
  31. t.modules.keyboard = true -- 是否开启键盘模块(布尔值)
  32. t.modules.math = true -- 是否开启数学模块(布尔值)
  33. t.modules.mouse = true -- 是否开启鼠标模块(布尔值)
  34. t.modules.physics = true -- 是否开启物理模块(布尔值)
  35. t.modules.sound = true -- 是否开启音效模块(布尔值)
  36. t.modules.system = true -- 是否开启系统模块(布尔值)
  37. t.modules.timer = true -- 是否开启计时器模块(布尔值)
  38. t.modules.window = true -- 是否开启窗口模块(布尔值)
  39. t.modules.thread = true -- 是否开启线程模块(布尔值)
  40. end

如果现在运行游戏,你会看到一个比之前小很多的窗口。

现在,当我们放大窗口至目标分辨率时,为了得到像素化观感还需要一些额外的工作。如果你现在在窗口中心(坐标 gw / w, gh / w)画一个圆,你会看到下面的效果:

第五章 游戏基础 - 图6

通过调用love.window.setMode来直接放大窗口,比如设置宽3 * gw和高3 * gh,你将得到下面的效果:

第五章 游戏基础 - 图7

如你所见,圆并没有随着窗口的放大而放大,仍然是一个小圆。而且它也没有保持在窗口中心,因为当窗口缩放 3 倍后,gw / 2gh / 2并不再是窗口的正中心位置。我们想要的是,在基础分辨率480 x 270下绘制一个小圆,当窗口缩放至目标分辨率来适配普通显示器时,圆也会按比例放大(以像素化的方式),并且它的位置也按比例保持不变。最简单实现该效果的方式是使用画布(Canvas),在其它游戏引擎中,它也被称为 framebuffer 或 render target。首先,我们将在Stage类的构造函数里创建一个具有基础分辨率的画布:

  1. function Stage:new()
  2. self.area = Area(self)
  3. self.main_canvas = love.graphics.newCanvas(gw, gh)
  4. end

这将创建一个尺寸为480 x 270的画布,然后我们可以对其进行绘制:

  1. function Stage:draw()
  2. love.graphics.setCanvas(self.main_canvas)
  3. love.graphics.clear()
  4. love.graphics.circle('line', gw/2, gh/2, 50)
  5. self.area:draw()
  6. love.graphics.setCanvas()
  7. end

这里展示的绘制画布的用法只是简单地参考了画布文档的示例。根据页面上的内容,当我们想在画布上绘制内容时,我们需要调用love.graphics.setCanvas,它会将所有绘制操作重定向到当前设置好的画布上。接着,我们调用love.graphics.clear,它将清除此画布上已经绘制过的内容,因为上一帧我们也是用它绘制的,每一帧我们都需要从头开始重新绘制我们需要的内容。然后,我们就可以绘制我们需要的内容。最后,再次调用setCanvas,然而这一次不用传递任何参数,因此,之后再绘制的内容也不会被重定向当我们的画布上了。

如果我们在此停止,则屏幕上不会显示任何内容。是因为,我们绘制的所有内容都在画布上,但我们并没有绘制画布本身(即没有将画布的内容显示到屏幕上)。因此,现在我们需要将画布本身绘制到屏幕上,代码如下所示:

  1. function Stage:draw()
  2. love.graphics.setCanvas(self.main_canvas)
  3. love.graphics.clear()
  4. love.graphics.circle('line', gw/2, gh/2, 50)
  5. self.area:draw()
  6. love.graphics.setCanvas()
  7. love.graphics.setColor(255, 255, 255, 255)
  8. love.graphics.setBlendMode('alpha', 'premultiplied')
  9. love.graphics.draw(self.main_canvas, 0, 0, 0, sx, sy)
  10. love.graphics.setBlendMode('alpha')
  11. end

我们只是简单地调用love.graphics.draw将画布绘制到屏幕上,还在调用前后用了一些love.graphics.setBlendMode函数来进行设置。根据 LÖVE 维基上的说明,这样设置用于避免不正确的混合发生,如果你现在再运行游戏,则应该看到圆被绘制了出来。

请注意,我们使用sxsy两个变量来放大画布,这些变量现在设置为 1,如果我们将其更改为 3,则显示效果如下:

第五章 游戏基础 - 图8

你什么也看不见!但这是因为之前位于480 x 270画布中间的圆,现在位于扩大 3 倍后1440 x 810画布的中间。由于我们屏幕还是只有480 x 270,因此此时圆位于可视范围之外。为了解决该问题,我们可以在main.lua中创建一个名为resize的函数,该函数将在每次改变屏幕尺寸或主动改变sxsy时被调用:

  1. function resize(s)
  2. love.window.setMode(s*gw, s*gh)
  3. sx, sy = s, s
  4. end

如果我们在love.load函数中调用resize(3),将得到下面这样的效果:

第五章 游戏基础 - 图9

这正是我们想要的。不过这里还有一个问题,圆看起来有些模糊,而不是被正确的像素化。

原因在于,无论何时在 LÖVE 按比例缩放对象时,都会使用 FilterMode,并且默认情况下此模式被设置为'linear',由于我们希望游戏具有像素风,我们应将其改为'nearest',通过在love.load开始时调用love.graphics.setDefaultFilter并传递'nearest'参数可以解决该问题。另一件事是将 LineStyle 设置为'rough'。由于默认情况下,它被设置为'smooth',LÖVE 图元在被绘制时会进行适当的抗锯齿,这也不适用于像素风。如果你修改完代码,在运行游戏,你将得到如下的效果:

第五章 游戏基础 - 图10

它看起来就是我们想要的效果!最重要的是,我们现在可以使用一种分辨率来开发我们整个游戏。如果我们想要在屏幕中心生成一个对象,那么它的位置就在gw / 2, gh / 2,无论我们最终显示成多少分辨率,它始终在屏幕中心,这大大简化了我们对屏幕适配的处理。这意味着我们只需要关心在一个分辨率下游戏表现效果以及对象在屏幕上的位置即可。

游戏显示尺寸练习

  1. 查看 Steam 硬件调查中的 Primary Display Resolution 这一部分。最流行的,使用超过半数的分辨率是1920x1080,我们这款游戏基础分辨率可以整数倍放大至1920x1080。第二受欢迎的分辨率是1366x768480x270不能整数倍放大至该分辨率。思考,当游戏全屏显示在玩家屏幕上时,哪些选项可以用来处理这些独特的分辨率?

  2. 选择一个与我们这里使用的技术类似的游戏(将一个小的基础分辨率缩放至目标分辨率)。通常,像素风游戏会使用该技术。思考,该游戏的基础分辨率是多少?该游戏是如何处理不能整数倍扩大的独特分辨率?更改你电脑的分辨率以观察在不同分辨率下,该游戏显示效果会发生什么变化?如何处理这些变化?

摄像机

所有房间都将使用到摄像机,因此我们现在就可以开始介绍摄像机了。在本系列的第二篇文章中,我们使用了一个名为 hump 的库中的计时器部分。这个库还有一个实用的摄像机模块,我们也将使用它。不过,我是用的是它经过略微修改后的版本,为其添加了震屏功能。你可以在这里下载修改后的文件。将camera.lua放置在 hump 库所在的目录下(将已存在的camera.lua覆盖掉),之后再main.luarequire它。并将Shake.lua文件放置在objects目录下。

注:此外,你还可以使用我编写的这个库,它具备所有功能。我在写完整个教程之后才着手开发了该库,因此该教程将继续假装没有该库存在。如果你选择使用该库,你可以继续跟着教程,不过可能需要将其中某些用法替换为该库的用法。

当你添加了摄像机模块后,你还需要新增一个函数:

  1. function random(min, max)
  2. local min, max = min or 0, max or 1
  3. return (min > max and (love.math.random()*(min - max) + max)) or (love.math.random()*(max - min) + min)
  4. end

此函数允许你在任意两个数字之间获取一个随机数。这是必需的,因为Shake.lua中使用到了它。在utils.lua中定义该函数之后,尝试执行如下代码:

  1. function love.load()
  2. ...
  3. camera = Camera()
  4. input:bind('f3', function() camera:shake(4, 60, 1) end)
  5. ...
  6. end
  7. function love.update(dt)
  8. ...
  9. camera:update(dt)
  10. ...
  11. end

接着在Stage类中做如下修改:

  1. function Stage:draw()
  2. love.graphics.setCanvas(self.main_canvas)
  3. love.graphics.clear()
  4. camera:attach(0, 0, gw, gh)
  5. love.graphics.circle('line', gw/2, gh/2, 50)
  6. self.area:draw()
  7. camera:detach()
  8. love.graphics.setCanvas()
  9. love.graphics.setColor(255, 255, 255, 255)
  10. love.graphics.setBlendMode('alpha', 'premultiplied')
  11. love.graphics.draw(self.main_canvas, 0, 0, 0, sx, sy)
  12. love.graphics.setBlendMode('alpha')
  13. end

当你按f3键时,你会看到屏幕震动:

第五章 游戏基础 - 图11

震动函数的实现基于这篇文章所述内容,它具有振幅(以像素为单位)、频率和持续时间。屏幕将从给定的振幅开始,不断衰减,按照一定频率,震动持续数秒。较高的频率意味着屏幕将在两极(amplitude, -amplitude)之间剧烈波动,而较低的频率则相反。

另一个需要注意的重要事项是,摄像机现在还没有固定在某个位置上。因此,当它震动时,它会向四面八方移动,结束时并不会归位,如前面的动图所示。

解决此问题的一种方法是,将其锁定在中间位置,这可以通过camera.lockPosition函数做到。在摄像机模块的修改版本中,我修改了所有移动函数,令它们首先接受一个dt参数。因此代码如下所示:

  1. function Stage:update(dt)
  2. camera.smoother = Camera.smooth.damped(5)
  3. camera:lockPosition(dt, gw/2, gh/2)
  4. self.area:update(dt)
  5. end

将摄像机平滑器的damped设置为 5,这是通过反复实验得出的经验值,它使得摄像机通过一个平滑和优雅的方式聚焦到目标点上。我之所以将这段代码放在Stage类中,是因为我们现在正在使用Stage房间,并且这个房间里的摄像机正好需要被固定在屏幕中心永远不会移动(除了屏幕震动)。效果如下所示:

第五章 游戏基础 - 图12

由于我们没有为每个房间都创建一个单独摄像机的需求,我们将会在游戏中使用一个全局摄像机。Stage房间只会使用到相机的震屏功能,因此,摄像机的介绍就先到这里。后面Console房间和SkillTree房间都将更加广泛地使用到摄像机功能,到时我们再继续接着介绍。

玩家物理

现在我们已经完成了开始构建游戏本体的一切准备,我们将从Player对象开始。在objects目录下创建一个新的文件,命名为Player.lua,内容如下所示:

  1. Player = GameObject:extend()
  2. function Player:new(area, x, y, opts)
  3. Player.super.new(self, area, x, y, opts)
  4. end
  5. function Player:update(dt)
  6. Player.super.update(self, dt)
  7. end
  8. function Player:draw()
  9. end

这是游戏中新游戏对象创建出来时的默认行为。它们都将继承自GameObject,并且都具有相同形式的构造函数、update函数和draw函数。现在,我们可以在Stage房间中实例化这个玩家对象:

  1. function Stage:new()
  2. ...
  3. self.area:addGameObject('Player', gw/2, gh/2)
  4. end

为了测试实例化是否起作用,以及玩家实例是否被正确地updatedraw,我们可以简单地在它的位置处绘制一个圆:

  1. function Player:draw()
  2. love.graphics.circle('line', self.x, self.y, 25)
  3. end

现在屏幕的中心应该出现一个圆,有意思的是,调用addGameObject函数会将创建出的对象返回,因此我们可以在Stageself.player中保留对玩家实例的引用,之后,如果我们愿意,我们可以通过键绑定来触发玩家对象的死亡:

  1. function Stage:new()
  2. ...
  3. self.player = self.area:addGameObject('Player', gw/2, gh/2)
  4. input:bind('f3', function() self.player.dead = true end)
  5. end

如果你按下f3键,玩家对象将被销毁,屏幕上的圆也就消失了。之所以产生这样的效果,是因为我们在前面文章中对Area对象进行了一系列设计而导致的。同样需要重点注意的是,如果你决定像上面这样保留addGameObject返回的引用,则如果不在必要的时机将保留的引用设置为nil的话,保留的引用对应的对象将永远不会被垃圾回收。因此必须时刻谨记对希望从内存中真正删除(即将其dead属性设置为true)的对象要清零引用(在上述例子中,需要通过self.player = nil)。


接下来介绍一下物理部分。玩家(以及敌人、弹幕和各种资源)都将是物理对象。为此,我将使用 LÖVE 集成的 box2d,不过对于本游戏来说,这也不是必需的,本游戏物理部分非常简单以至于 box2d 这样完整的物理引擎有些大材小用。我选择使用它仅仅是因为我已经习惯了。不过我强烈建议你尝试自己去解决碰撞检测问题(对于本游戏来说,这很容易做到),或者使用其他库来处理。

本教程将使用我创建的名为 windfield 的库,该库简化了 box2d 在 LÖVE 中的使用。还有一些在 LÖVE 处理碰撞的库,比如 HardonColliderbump.lua

我强烈建议你自己处理碰撞检测,或者使用上述提到的其他两个库之一,而不是本教程中使用的库。这是因为这将给你带来一系列必要的锻炼,例如在各种不同的解决方案中进行选择,审视哪个库更适合自己的需求或哪个库的解决方案最优秀,以及提出自己的解决方案来解决将要面临的问题,而不是仅仅跟随教程。

再重申一遍,本教程拥有练习的主要原因之一是希望人们能够积极地对提供的资源进行各种尝试,以便在实践中学习,目前物理模块就是尝试自己动手的另一个机会。如果你只是跟随教程,并且不去了解自己不知道的事情,那么你将永远无法真正掌握游戏开发。因此,我再次强烈建议你此刻可以抛开教程,自行完成游戏中的物理/碰撞部分。

无论如何,你都可以下载windfield库,并且在main.lua文件中require它。根据其文档,这里与两个主要概念:WorldColliderWorld是所有模拟发生的物理世界,Collider(碰撞体)是物理世界中正在模拟的物理对象。因此,我们的游戏需要一个物理世界,我们的玩家对象将是物理世界中的一个碰撞体。

我们将通过Area类中的addPhysicsWorld调用来创建一个物理世界:

  1. function Area:addPhysicsWorld()
  2. self.world = Physics.newWorld(0, 0, true)
  3. end

这将为我们的Area设置一个.world变量来引用我们刚创建出来的物理世界。如果当前的Area存在物理世界,我们还需要更新它(也可以绘制它用来 Debug):

  1. function Area:update(dt)
  2. if self.world then self.world:update(dt) end
  3. for i = #self.game_objects, 1, -1 do
  4. ...
  5. end
  6. end
  7. function Area:draw()
  8. if self.world then self.world:draw() end
  9. for _, game_object in ipairs(self.game_objects) do game_object:draw() end
  10. end

在更新所有游戏对象之前,我们先更新物理世界。因为更新游戏对象时,我们希望使用的是游戏对象最新的信息(位置等),这些信息只有物理世界进行物理模拟之后才会更新。如果我们先更新游戏对象,那么它们将使用的是上一帧中的物理信息,这有些破坏帧边界(即当前帧逻辑使用的是上一帧的数据)。据我所知,它并不会改变整个游戏的运行方式,但从概念上来讲令人难以理解。

我们通过addPhysicsWorld添加一个物理世界而不是为每一个Area直接在构造函数中创建一个,是因为并不是所有的Area都将拥有物理世界。例如Console房间将使用Area来管理其中的对象,但它只是一个选项界面,不需要一个物理世界来模拟物理行为。所以通过一个函数来主动添加物理世界是有意义的。我们可以如下实例化这个物理世界:

  1. function Stage:new()
  2. self.area = Area(self)
  3. self.area:addPhysicsWorld()
  4. ...
  5. end

现在我们有了一个可以添加碰撞体的物理世界:

  1. function Player:new(area, x, y, opts)
  2. Player.super.new(self, area, x, y, opts)
  3. self.x, self.y = x, y
  4. self.w, self.h = 12, 12
  5. self.collider = self.area.world:newCircleCollider(self.x, self.y, self.w)
  6. self.collider:setObject(self)
  7. end

注意,Player有一个对Area的引用这里就派上用场了,因为这样我们就可以直接访问Area的物理世界,并为其添加新的碰撞体。这种模式(直接访问Area的属性)后面还会出现很多次,这样做所有的GameObject都具有相同的构造函数,并在其中设置它们所属的Area的引用。

无论如何,在Player的构造函数中,我们定义并设置wh为 12。接着我们使用该值作为半径为物理世界新增了一个 CircleCollider(圆形碰撞体)。目前来说,我们为Player定义了宽和高,但却为它创建了一个圆形的碰撞体,这似乎不符合逻辑(为什么不创建一个矩形碰撞体),但随着未来我们为游戏添加不同类型的飞船后,这些飞船从视觉上看都不一样,但从物理上讲,为了不同飞船的平衡以及可预测的手感,它们的碰撞体都将是圆形。

添加碰撞体之后,我们调用setObject函数将玩家对象和碰撞体对象绑定在一起。这非常有意义,当两个碰撞体发生碰撞时,我们只能通过碰撞体获取信息而没有办法直接通过对象获取。例如,如果玩家和抛射物(子弹等)发生碰撞,我们只能得到两个碰撞体,一个代表玩家,一个代表抛射物,而不能直接获取它们的GameObject对象。使用setObject(和getObject)允许我们为碰撞体设置和提取它所属的游戏对象。

最后,我们可以根据玩家大小来绘制它:

  1. function Player:draw()
  2. love.graphics.circle('line', self.x, self.y, self.w)
  3. end

如果你现在运行游戏,你将看到如下效果:

第五章 游戏基础 - 图13

玩家物理练习

如果你选择自己实现碰撞效果,或者决定使用之前提到的其余 2 个库来实现物理/碰撞,那么你无需进行下面的练习。

  1. 将物理世界的 y 轴重力更改为 512,思考:Player对象会发生什么?

  2. 思考:.newWorld函数的第三个参数有什么意义?如果将其设置为false将会发生什么?将其设置为truefalse分别有什么优缺点?

玩家移动

该游戏中,玩家移动的方式是:玩家会以一个固定速度向前移动,可以通过按住左键或右键来改变角度。为了达到该效果,我们需要定义一些变量:

  1. function Player:new(area, x, y, opts)
  2. Player.super.new(self, area, x, y, opts)
  3. ...
  4. self.r = -math.pi/2
  5. self.rv = 1.66*math.pi
  6. self.v = 0
  7. self.max_v = 100
  8. self.a = 100
  9. end

这里,我定义了一个变量r来表示玩家移动的朝向,其初始值为-math.pi/2(指向上方)。在 LÖVE 中,角度是按照顺时针方向设计的,这意味着math.pi/2表示向下,-math/pi/2表示向上(向右为 0)。接着,rv变量表示当玩家按下向左或向右时角度改变的速度(即角速度)。然后,我们还有表示玩家速度的变量v以及可能达到的最大速度max_v。最后是变量a,表示玩家的加速度。这些都是反复试错得到的经验结果。

要使用这些变量来更新我们玩家的位置,代码如下所示:

  1. function Player:update(dt)
  2. Player.super.update(self, dt)
  3. if input:down('left') then self.r = self.r - self.rv * dt end
  4. if input:down('right') then self.r = self.r + self.rv * dt end
  5. self.v = math.min(self.v + self.a * dt, self.max_v)
  6. self.collider:setLinearVelocity(self.v * math.cos(self.r), self.v * math.sin(self.r))
  7. end

代码前两行定义了当玩家按下左键或右键时的行为,需要注意的是,由于我们使用的 Input 库,我们需要事先定义这些输入事件绑定(具体可参看前面的教程),因此我在main.lua中定义这些绑定(因为我们将会使用一个全局 Input 对象来处理一切输入):

  1. function love.load()
  2. ...
  3. input:bind('left', 'left')
  4. input:bind('right', 'right')
  5. ...
  6. end

因此,当用户按下左键或右键时,代表玩家当前朝向的变量r将在对应的方向上改表1.66*math.pi弧度。这里需要注意的一件事是我们将该值乘以dt,这实际上意味着该值表示的是每秒产生的变化(即rv的单位是弧度/秒)。所以,当按下方向键时,玩家每秒会转动1.66*math.pi弧度。这是我们第一篇文章中介绍的游戏循环如果工作产生的结果。

此后,我们设置v的值。这一步涉及到的内容更多,不过如果你使用其他编程语言完成过此操作,应该会非常熟悉。计算速度的原始方式是self.v = self.v + self.a * dt,它只是通过加速度和时间来改表速度(根据物理公式v=v0+at)。在本游戏中,我们将其每秒增加 100。不过我们还定义了最大速度max_v属性,如果我们不限制最大速度,则self.v = self.v + self.a * dt将不断地增加v的值,玩家将成为索尼克。我们不想成为那样,因此,我们需要添加下面的代码来防止该情况出现:

  1. function Player:update(dt)
  2. ...
  3. self.v = self.v + self.a * dt
  4. if self.v >= self.max_v then
  5. self.v = self.max_v
  6. end
  7. ...
  8. end

通过这种方式,每当v超过max_v时,它就会被设置为最大值,避免越过最大值。另一种更简洁的方式是使用math.min函数,该函数返回传递给它的所有参数中的最小值。在该游戏中,我们将self.v + self.a * dtself.max_v传递给它,如果前面的加法结果超过max_v,我们就会得到max_v。在 Lua(以及其他编程语言中)这是一种非常常见且实用的技巧。

最后,我们使用setLinearVelocity将碰撞体的 x 轴速度和 y 轴速度分别设置为玩家速度v乘上根据对象当前角度值算出的某个值。通常情况下,你想将某个对象沿着某个角度进行移动,可以通过计算该角度的cos值来得到其 x 轴分量,通过计算该角度的sin值来得到其 y 轴分量,这也是 2D 游戏开发常见的技巧。我将假设你在学校里学过相关知识并理解为什么要这么做(如果你还不太了解,可以通过搜索引擎搜索三角函数)。

我们还需要对GameObject类进行一些简单的修改。因为我们使用了物理引擎,所以这里会有两套表示相同含义的变量,如位置和速度(其中一套是我们自己定义的,另一套是物理引擎中定义的)。我们可以通过Playerxy来访问它的位置,v来访问它的速度,同样可以通过碰撞体的getPosition函数来访问其位置,getLinearVelocity函数来访问其速度。保持这两套数值同步是一个好的设计,一种自动实现上述需求的方法是更改所有游戏对象父类:

  1. function GameObject:update(dt)
  2. if self.timer then self.timer:update(dt) then
  3. if self.collider then self.x, self.y = self.collider:getPosition() end
  4. end

我们这里只是简单地判断了一下游戏对象是否拥有碰撞体,如果有,则直接把游戏对象的位置设置成碰撞体的位置。每当碰撞体位置发生变化的时候,游戏对象本身的位置也会相应改变。

如果你现在运行游戏,你将看到如下所示:

第五章 游戏基础 - 图14

现在,你可以看到Player对象可以正常移动并且通过左键和右键来控制其移动方向。这里有一个重要的细节,我们现在显示的内容是通过Area对象中调用world:draw()绘制出的碰撞体。我们不希望只绘制碰撞体,因此将这行代码注释掉并且直接在Player对象中绘制其效果:

  1. function Player:draw()
  2. love.graphics.circle('line', self.x, self.y, self.w)
  3. end

我们要做的最后一件事是将玩家的朝向可视化。我们简单地绘制一条从玩家位置触发到玩家朝向的线来表示:

  1. function Player:draw()
  2. love.graphics.circle('line', self.x, self.y, self.w)
  3. 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))
  4. end

这看起来像下面这样:

第五章 游戏基础 - 图15

这也是一个简单的三角函数应用。通常,如果你想获取距离位置 A 某个长度且在某个角度上的位置 B,你可以通过bx = ax + distance * math.cos(angle)来计算位置 B 的 x 坐标,by = ay + distance * math.sin(angle)来计算位置 B 的 y 坐标。这些在 2D 游戏开发中也是非常常见的(至少在我的经验中),了解这些数学运算是如何生效的是非常有用的。

玩家移动练习

  1. 将以下弧度值转换成角度(在你的脑海里),并说出它们分别属于哪个象限(左上、右上、左下或右下)。请注意,在 LÖVE 中,角度是按照顺时针方式处理的,而不是像你在学校学的那样按照逆时针处理。

    1. math.pi/2
    2. math.pi/4
    3. 3*math.pi/4
    4. -5*math.pi/6
    5. 0
    6. 11*math.pi/12
    7. -math.pi/6
    8. -math.pi/2 + math.pi/4
    9. 3*math.pi/4 + math.pi/3
    10. math.pi
  2. 思考:加速度属性a是否真正需要?如果它不存在Playerupdate函数该如何实现?这样做有什么好处?

  3. 已知点 A 的坐标,点 B 在点 A 的-math.pi/4角度方向,并且距离其 100,求点 B 的坐标。

    第五章 游戏基础 - 图16

  4. 点 C 在点 B 的math.pi/4角度方向,并且距离其 50,其他条件同上一个练习,求点 C 的坐标。

    第五章 游戏基础 - 图17

  5. 基于前两个练习,思考:当你想从点 A 到达某点 C,你只知道从点 A 出发的一系列角度和距离,求点 C 坐标的通用模式。

  6. 上文提到了同步Player和碰撞体的位置和速度,那么旋转应该如何处理?碰撞体可以通过getAngle来获取其角度,为什么不同时将其同步到Playerr属性呢?

垃圾回收

目前,我们已经添加了物理引擎和一些控制玩家移动的逻辑,接下来我们专注解决到目前为止一直被我们忽略的问题——内存泄漏。无论在什么样的编程环境下,内存泄漏都有可能发生,并且它会带来各种各样不好的影响。在如 Lua 这样的托管内存编程语言(即开发人员不用自己关心内存的申请和回收)中,这可能是一个更令人困扰的问题,因为与 C++ 这种自己可以完全控制内存的编程语言相比,Lua 关于内存回收的一切都是黑盒的。

垃圾回收器的工作原理是:当一个对象从根节点对象集中出发不可达时(即从根节点对象集中出发,经过 n 层引用,也没有一个对该对象的引用),它将被回收。比如,你有一个表,它被唯一一个变量a引用,当执行a = nil时,垃圾回收器能够识别该表不再被引用,在下一个垃圾回收周期中将其从内存中删除。当一个对象被多次引用,你又忘记将所有的引用都解除时,问题就出现了。

举个例子,当我们使用addGameObject创建一个新的游戏对象时,它会被添加到Area.game_objects列表中,这会被视为对该对象的一个引用。不过,调用该函数还会将创建的对象返回出去。我们之前有类似的逻辑self.player = self.area:addGameObject('Player', ...),这意味着,除了刚才说的.game_objects对创建的对象有一个引用,self.player也对该变量有一个引用。当我们设置self.player.dead时,虽然将其从Area的游戏对象列表中移除,但是它仍然不会被垃圾回收,因为self.player依旧引用着它。对于该实例来说,要想将该对象真正释放,既需要设置其deadtrue,还需要设置self.player = nil

以上只是内存泄漏可能发生的一个案例,实际在项目中,内存泄漏可能发生在任何地方,当你使用其他人开发的库时更需要小心。例如,我创建的物理库中有setObject方法,通过它你可以为碰撞体设置一个游戏对象的引用。当该游戏对象死亡时,它会从内存中移除吗?并不会,因为碰撞体仍然持有对其的引用。同样的问题,只是应用场景不同。解决该问题的一种方法是,通过为对象提供明确的destroy函数,在该函数中解决所有有关该对象引用问题。

因此,我们可以将以下内容添加给所有对象:

  1. function GameObject:destroy()
  2. self.timer:destroy()
  3. if self.collider then self.collider:destroy() end
  4. self.collider = nil
  5. end

现在,所有的对象都有一个默认的destroy函数。这个函数同调用碰撞体的销毁方法一样调用 EnhancedTimer 对象的destroy函数。这些函数实际上做的事情就是取消引用用户希望从内存中删除的对象。例如,在Collider:destroy中,执行的逻辑之一就是调用self:setObject(nil),只有不在引用我们设置的游戏对象,游戏对象才能顺利地从内存中释放。

接着,我们可以将Areaupdate函数改成如下这样:

  1. function Area:update(dt)
  2. if self.world then self.world:update(dt) end
  3. for i = #self.game_objects, 1, -1 do
  4. local game_object = self.game_objects[i]
  5. game_object:update(dt)
  6. if game_object.dead then
  7. game_object:destroy()
  8. table.remove(self.game_objects, i)
  9. end
  10. end
  11. end

当一个对象的dead属性被设置为true时,除了将其从游戏对象列表中移除以外,还将调用其destroy函数,从而解决对它的大部分引用。我们可以进一步扩展这个思路,考虑到物理世界本身也具有一个World:destroy函数,我们可以在销毁Area对象的时候,使用它来销毁物理世界:

  1. function Area:destroy()
  2. for i = #self.game_objects, 1, -1 do
  3. local game_object = self.game_objects[i]
  4. game_object:destroy()
  5. table.remove(self.game_objects, i)
  6. end
  7. self.game_objects = {}
  8. if self.world then
  9. self.world:destroy()
  10. self.world = nil
  11. end
  12. end

当我们销毁一个Area时,我们先销毁它管理的所有游戏对象,然后我们再销毁它创建的物理世界。现在,我们可以修改Stage房间的代码来配合上面这些改动:

  1. function Stage:destroy()
  2. self.area:destroy()
  3. self.area = nil
  4. end

我们还要修改gotoRoom函数:

  1. function gotoRoom(room_type, ...)
  2. if current_room and current_room.destroy then current_room:destroy() end
  3. current_room = _G[room_type](...)
  4. end

我们检查current_room变量是否存在,并且查看它是否拥有destroy函数(换句话说,我们检查该变量是否保存着一个真实的房间),如果都没问题,我们就调用它的destroy函数。紧接着我们继续切换至目标房间。

重要的是还要记住,现在添加了destroy函数后,所有对象都必须遵循以下模板:

  1. NewGameObject = GameObject:extend()
  2. function NewGameObject:new(area, x, y, opts)
  3. NewGameObject.super.new(self, area, x, y, opts)
  4. end
  5. function NewGameObject:update(dt)
  6. NewGameObject.super.update(self, dt)
  7. end
  8. function NewGameObject:draw()
  9. end
  10. function NewGameObject:destroy()
  11. NewGameObject.super.destroy(self)
  12. end

到目前为止,一切非常顺利,但我们如何测试这些对象是否真的从内存中释放了呢?我喜欢的一篇[文章]回答了这个问题,它提供了一个相对简单的解决方案来跟踪内存泄漏问题:

  1. function count_all(f)
  2. local seen = {}
  3. local count_table
  4. count_table = function(t)
  5. if seen[t] then return end
  6. f(t)
  7. seen[t] = true
  8. for k,v in pairs(t) do
  9. if type(v) == "table" then
  10. count_table(v)
  11. elseif type(v) == "userdata" then
  12. f(v)
  13. end
  14. end
  15. end
  16. count_table(_G)
  17. end
  18. function type_count()
  19. local counts = {}
  20. local enumerate = function (o)
  21. local t = type_name(o)
  22. counts[t] = (counts[t] or 0) + 1
  23. end
  24. count_all(enumerate)
  25. return counts
  26. end
  27. global_type_table = nil
  28. function type_name(o)
  29. if global_type_table == nil then
  30. global_type_table = {}
  31. for k,v in pairs(_G) do
  32. global_type_table[v] = k
  33. end
  34. global_type_table[0] = "table"
  35. end
  36. return global_type_table[getmetatable(o) or 0] or "Unknown"
  37. end

在这里,我不会去解释这些代码,因为上面提到的文章对其进行了解释,将这段代码添加到main.lualove.load中:

  1. function love.load()
  2. ...
  3. input:bind('f1', function()
  4. print("Before collection: " .. collectgarbage("count")/1024)
  5. collectgarbage()
  6. print("After collection: " .. collectgarbage("count")/1024)
  7. print("Object count: ")
  8. local counts = type_count()
  9. for k, v in pairs(counts) do print(k, v) end
  10. print("-------------------------------------")
  11. end)
  12. ...
  13. end

这样做的结果是,每当你按下f1键,它将向你展示垃圾回收周期之前的内存量及之后的内存量,并展示当前内存中不同的对象类型分别的数量。这非常实用,现在,我们可以创建一个新的Stage,并添加很多游戏对象,再将其删掉,然后看看内存是否与创建Stage之前相同(或增长在一个可接受的范围内)。如果它保持不变,则说明我们并没有内存泄漏,反之则存在,我们需要进一步找出泄露的原因。

第五章 游戏基础 - 图18

垃圾回收练习

  1. 绑定f2键,通过调用gotoRoom函数来创建并激活一个新的Stage

  2. 绑定f3键来销毁当前房间。

  3. 通过多次按下f1键来检查内存使用情况。然后按几次f2f3来创建和销毁新房间。再通过按下f1键来检查当前内存使用情况,内存使用总量和第一次一样多吗?

  4. 通过执行以下操作,将Stage房间设置为生成 100 个Player对象而不是仅生成 1 个:

    1. function Stage:new()
    2. ...
    3. for i = 1, 100 do
    4. self.area:addGameObject('Player', gw/2 + random(-4, 4), gh/2 + random(-4, 4))
    5. end
    6. end

    再修改Playerupdate函数,使其不再移动(注释掉移动的代码)。现在,重复上一个练习的过程。查看使用的内存总量是否不同?整体结果会改变吗?


上一章 练习

下一章 玩家基本功能