第五章 玩家基本功能

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

简介

在本章中,我们将重点为Player类添加更多功能。首先,我们将重点放在玩家的攻击和发射子弹对象上。之后,我们将重点关注玩家的 2 个主要特性:加速(Boost)效果和更新(Cycle/Tick)效果。最后,我们将添加一个视觉效果完全不同的飞船到游戏中。从这一章开始,我们将只关注游戏性方面的内容,而前五章主要是基础建设(可以适用于任意游戏)。

玩家攻击

在此游戏中,玩家攻击的方式是:每隔 n 秒就会触发一次自动攻击。最终我们将拥有 16 中攻击类型,但几乎所有的攻击方式都是朝着玩家面对的方向发射不同的子弹。例如,下面是发射追踪导弹的效果:

第五章 玩家基本功能 - 图1

下面这个尽管设计速度更快,但发射的角度有些随机:

第五章 玩家基本功能 - 图2

虽然攻击和发射的子弹具有各种不同的属性,并且它们会受到不同事物的影响,但其核心逻辑始终是相同的。

为了达到上述效果,实现我们需要实现玩家每隔 n 秒攻击的逻辑。n 是一个根据攻击而变化的数字,默认值为 0.24。使用前面介绍的 Timer 库,我们可以轻松做到这一点:

  1. function Player:new()
  2. ...
  3. self.timer:every(0.24, function()
  4. self:shoot()
  5. end)
  6. end

添加上述代码后,我们将每隔 0.24 秒调用一次Shoot函数,我们将在该函数内添加实际创建子弹对象的代码。

现在,我们可以来设计Shoot函数内的逻辑了。首先,对于每一次设计,我们都将产生一个很小的视觉效果,用来表示当前射击了。我有一个好的经验法则是:每当实体对象在游戏中被创建或删除时,都为其添加一个伴随的视觉效果,这样掩盖了实体对象在屏幕上凭空出现和消失的事实,并且通常会使游戏感觉更棒。

要创建这个效果,首先我们需要创建一个名为ShootEffect的类(现在的你应该知道该如何创建)。这个效果只是在将要创建子弹的位置生成一个持续非常短时间的正方形。最简单的实现方法如下:

  1. function Player:shoot()
  2. self.area:addGameObject('ShootEffect', self.x + 1.2*self.w*math.cos(self.r),
  3. self.y + 1.2*self.w*math.sin(self.r))
  4. end
  1. function ShootEffect:new(...)
  2. ...
  3. self.w = 8
  4. self.timer:tween(0.1, self, {w = 0}, 'in-out-cubic', function() self.dead = true end)
  5. end
  6. function ShootEffect:draw()
  7. love.graphics.setColor(default_color)
  8. love.graphics.rectangle('fill', self.x - self.w/2, self.y - self.w/2, self.w, self.w)
  9. end

看起来如下所示:

第五章 玩家基本功能 - 图3

上述效果代码非常直接。它只是一个变长为 8 的正方形,生命周期为 0.1 秒,并且在生命周期内,边长逐渐变为 0。现在有一个问题是,这个特效的位置静态的,它不会随着玩家的移动而移动。这看起来只是一个小细节,因为这个特效持续的时间非常短,不过一旦你把持续时间调整成 0.5 秒或更长时,你就会明白我说的问题了。

解决此问题的一种方法是将Player对象作为参数传递给ShootEffect对象,这样就可以通过下面这种方式来将ShootEffect的位置同步到Player对象了:

  1. function Player:shoot()
  2. local d = 1.2*self.w
  3. self.area:addGameObject('ShootEffect',
  4. self.x + d*math.cos(self.r),
  5. self.y + d*math.sin(self.r),
  6. {player = self, d = d})
  7. end
  8. ```lua
  9. function ShootEffect:update(dt)
  10. ShootEffect.super.update(self, dt)
  11. if self.player then
  12. self.x = self.player.x + self.d*math.cos(self.player.r)
  13. self.y = self.player.y + self.d*math.sin(self.player.r)
  14. end
  15. end
  16. function ShootEffect:draw()
  17. pushRotate(self.x, self.y, self.player.r + math.pi/4)
  18. love.graphics.setColor(default_color)
  19. love.graphics.rectangle('fill', self.x - self.w/2, self.y - self.w/2, self.w, self.w)
  20. love.graphics.pop()
  21. end

通过opts表在玩家射击时,将ShootEffect对象的player属性设置为self。这意味着,在ShootEffect对象中,可以通过self.player来访问到对应的Player对象。通常,这便是我们将一个对象引用传递给另一个对象的方式,因为大部分情况下,我们都是通过某个对象的某个方法来创建另一个对象,此时self就是我们想传递的变量。此外,我们还设置了一个d属性,它表示我们希望攻击效果出现的位置离玩家中心的距离,也是通过opts表来完成传递的。

然后在ShootEffectupdate函数中,我们将其位置根据玩家的位置进行设置。务必始终检查要访问的引用是否被正确设置(通过if self.player then),因为如果没有正确设置引用,就会产生错误。很多时候,随着我们的代码量越来越大,会有很多对象在被其他对象引用时死亡,我们仍然会去访问这些对象的某些字段,但由于这些对象已经死亡,很可能对应的字段已经被置空,这时就会产生错误。像这样互相引用对象时,请务必牢记这一点。

最后一个细节是,我使方块效果与玩家的角度同步,然后再将其旋转 45 度以使其看起来更酷。通过pushRotate函数实现上述效果:

  1. function pushRotate(x, y, r)
  2. love.graphics.push()
  3. love.graphics.translate(x, y)
  4. love.graphics.rotate(r or 0)
  5. love.graphics.translate(-x, -y)
  6. end

这是一个将变换推入变换栈中的简单函数。实际上,它使所有接下来绘制的内容围绕x, y位置旋转r角度,直到我们调用love.graphics.pop。在上面示例中,我们将正方形效果围绕其中心点旋转玩家当前的角度再加上 45 度(pi / 4 弧度)。为了完整起见,这里还提供了一个包含缩放的函数版本:

  1. function pushRotateScale(x, y, r, sx, sy)
  2. love.graphics.push()
  3. love.graphics.translate(x, y)
  4. love.graphics.rotate(r or 0)
  5. love.graphics.scale(sx or 1, sy or sx or 1)
  6. love.graphics.translate(-x, -y)
  7. end

这些函数非常实用,还将在我们整个游戏中使用,因此请确保你会使用它们,并了解它们!

玩家攻击练习

  1. 如今,我们只需要在玩家的构造函数中使用默认构造的计时器,就可以每隔 0.24 秒调用一次射击函数。假设Player中存在一个self.attack_speed属性,该属性每 5 秒变化成 1 ~ 2 中的一个随机值:

    1. function Player:new(...)
    2. ...
    3. self.attack_speed = 1
    4. self.timer:every(5, function() self.attack_speed = random(1, 2) end)
    5. self.timer:every(0.24, function() self:shoot() end)

    思考:如何修改Player逻辑,使其每隔0.24 / self.attack_speed秒攻击一次?请注意,只是简单改一下every函数的第一个参数,是无法达到目的的。

  2. 在上一篇教程中,我们讨论了垃圾回收以及被遗忘的引用是多么危险且会导致内存泄漏问题。在本文中,我介绍了在PlayerShootEffect实例中互相引用的示例。在该示例中,ShootEffect是一个短生命周期的对象,其中包含了对Player对象的引用,思考:我们是否需要关心显示取消对Player的引用,以便垃圾回收器能正确地回收Player对象?更一般的情况,什么情况下我们需要关心取消这样相互引用的对象?

  3. 使用pushRotatePlayer对象绕其中心旋转 180 度。效果如下:

    第五章 玩家基本功能 - 图4

  4. 使用pushRotate将指示玩家移动方向的线绕其中心旋转 90 度。效果如下:

    第五章 玩家基本功能 - 图5

  5. 使用pushRotate将指示玩家移动方向的线绕玩家中心旋转 90 度。效果如下:

    第五章 玩家基本功能 - 图6

  6. 使用pushRotate将射击特效绕玩家中心旋转 90 度。效果如下:

    第五章 玩家基本功能 - 图7

玩家弹幕

现在我们已经完成了射击特效,接下来我们将实现实际发射出去的子弹。子弹的运动机制与玩家的非常相似,它是一个具有一定初始角度的物理对象,然后我们将根据该角度设置其速度。让我们从shoot函数内部开始:

  1. function Player:shoot()
  2. ...
  3. self.area:addGameObject('Projectile', self.x + 1.5*d*math.cos(self.r),
  4. self.y + 1.5*d*math.sin(self.r), {r = self.r})
  5. end

这些逻辑应该没有什么意外,我们使用上文提到的d变量来设置子弹的初始位置,然后将玩家的角度作为r属性进行传递。可以注意到,与ShootEffect对象不同,子弹在创建时,除了玩家的角度,不需要其他任何信息,因此我们不需要传递Player引用。

再来看一下子弹的构造函数。子弹对象将具有一个圆形碰撞体(像Player那样)属性、一个速度属性和一个指示其移动方向的属性:

  1. function Projectile:new(area, x, y, opts)
  2. Projectile.super.new(self, area, x, y, opts)
  3. self.s = opts.s or 2.5
  4. self.v = opts.v or 200
  5. self.collider = self.area.world:newCircleCollider(self.x, self.y, self.s)
  6. self.collider:setObject(self)
  7. self.collider:setLinearVelocity(self.v*math.cos(self.r), self.v*math.sin(self.r))
  8. end

s属性表示碰撞体的半径,这个属性名不是r是因为已经有一个属性r用来表示其移动的角度。通常,我会使用变量whrs来表示对象的尺寸。当对象是矩形时,我会使用前两个变量,当它是圆形时,我会使用后两个变量。如果r变量被用于表示某个方向时(如本例),则会用s表示半径。这些属性主要用于显示相关,因为大多数情况下,这些对象有对应的碰撞体来进行碰撞相关的工作。

这里我们所做的另一件事是,使用opts.attrbute or default_value进行构造(相关知识点我想已经在其他章节中介绍了)。借由 Lua 中or的工作方式,我们可以使用此写法来简化代码,逻辑等同于:

  1. if opts.attribute then
  2. self.attribute = opts.attribute
  3. else
  4. self.attribute = default_value
  5. end

我们先检查属性是否存在,如果存在,则直接使用它,否则使用默认值。在我们的代码中,如果opts.s存在,则将self.s设置为其值,否则将其设置为 2.5。self.v同理。最后,我们使用setLinearVelocity来设置子弹的速度,其参数来自子弹的初始速度和从Player传入的角度。这里使用了与Player一样的移动逻辑,因此你应该可以理解它。

如果我们现在更新并绘制子弹,例如:

  1. function Projectile:update(dt)
  2. Projectile.super.update(self, dt)
  3. self.collider:setLinearVelocity(self.v*math.cos(self.r), self.v*math.sin(self.r))
  4. end
  5. function Projectile:draw()
  6. love.graphics.setColor(default_color)
  7. love.graphics.circle('line', self.x, self.y, self.s)
  8. end

看起来如下:

第五章 玩家基本功能 - 图8

玩家弹幕练习

  1. 进入玩家射击函数,将创建出的弹幕尺寸(半径)改为 5,速度改为 150。

  2. 修改玩家射击函数,使其同时发射 3 个子弹,其中 2 个子弹发射的角度为玩家当前角度 +-30 度,看起来如下:

    第五章 玩家基本功能 - 图9

  3. 修改玩家射击函数,使其同时发射 3 个子弹,其中两边的 2 个子弹发射的位置为中间的子弹向两边偏移 8 个像素,看起来如下:

    第五章 玩家基本功能 - 图10

  4. 修改子弹初始速度为 100,当其创建出来后,在 0.5 秒内加速到 400。

玩家及弹幕死亡

既然玩家目前可以实现基本的移动和攻击,我们就可以开始思考游戏的一些附加规则了。其中之一是,如果玩家碰到游戏游玩区域的边界,他将死亡。弹幕也是如此,因为现在它们只有生成逻辑,但是从来不会死亡,随着它们数量越来越多,游戏的性能也会随之大大降低。

因此,让我们从Projectile对象开始:

  1. function Projectile:update(dt)
  2. ...
  3. if self.x < 0 then self:die() end
  4. if self.y < 0 then self:die() end
  5. if self.x > gw then self:die() end
  6. if self.y > gh then self:die() end
  7. end

我们知道,游戏可玩区域的中心是(gw/2, gh/2),左上角是(0, 0)点,右下角是(gw, gh)点。因此我们要做的就是向抛射物的update函数中新增一系列条件判断,来检查其当前位置是否超出了我们游戏可玩区域的边界,如果是,我们将调用die函数。

对于Player对象来说也是如此:

  1. function Player:update(dt)
  2. ...
  3. if self.x < 0 then self:die() end
  4. if self.y < 0 then self:die() end
  5. if self.x > gw then self:die() end
  6. if self.y > gh then self:die() end
  7. end

接下来我们看一下die函数。这个函数非常简单直接,他就是将当前实体对象的dead属性设置为true,并产生一些视觉效果。对抛射物来说,产生的效果对应生成的对象是ProjectileDeadEffect,就像ShootEffect那样,它的形状是一个正方形,持续存在一段时间后便消失,不过具体效果还是有一些差异。最大的差异是ProjectileDeadEffect将闪烁一会,然后再恢复为正常颜色,最后消失。在我看来,这是一个微妙但不错的弹出效果。其构造函数看起来如下所示:

  1. function ProjectileDeathEffect:new(area, x, y, opts)
  2. ProjectileDeathEffect.super.new(self, area, x, y, opts)
  3. self.first = true
  4. self.timer:after(0.1, function()
  5. self.first = false
  6. self.second = true
  7. self.timer:after(0.15, function()
  8. self.second = false
  9. self.dead = true
  10. end)
  11. end)
  12. end

我们定义了两个属性,firstsecond,它们将表示效果当前处于哪个阶段。如果在第一阶段,它的颜色将是白色,而在第二阶段,它的颜色将变成它应该显示的颜色。完成第二阶段后,效果通过将其dead属性设置为true从而消失。这一切都放生在 0.25 秒(0.1 + 0.15)的时间范围内,因此这是一个短暂且快速的效果。下面来看看该特效如何绘制到屏幕上,它的绘制方式与ShootEffect的非常相似:

  1. function ProjectileDeathEffect:draw()
  2. if self.first then love.graphics.setColor(default_color)
  3. elseif self.second then love.graphics.setColor(self.color) end
  4. love.graphics.rectangle('fill', self.x - self.w/2, self.y - self.w/2, self.w, self.w)
  5. end

正如我上文所解释的那样,这里我们仅仅根据不同的阶段来绘制不同颜色的矩形。我们将从Projectile对象的die函数中将其创建:

  1. function Projectile:die()
  2. self.dead = true
  3. self.area:addGameObject('ProjectileDeathEffect', self.x, self.y,
  4. {color = hp_color, w = 3*self.s})
  5. end

有一件我之前未提到的事情,该游戏将使用有限数量的颜色。我不是一名艺术家,我不想花太多时间来纠结选择哪种颜色,所以我只挑选了一些色彩搭配得很好的颜色,并在游戏各个地方使用它们。这些颜色在globals.lua中定义,如下所示:

  1. default_color = {222, 222, 222}
  2. background_color = {16, 16, 16}
  3. ammo_color = {123, 200, 164}
  4. boost_color = {76, 195, 217}
  5. hp_color = {241, 103, 69}
  6. skill_point_color = {255, 198, 93}

对于ProjectileDeathEffect,我选择使用hp_color(红色)。不过未来应该将其修改为弹幕的颜色。不同的攻击类型有不同的颜色,因此弹幕死亡效果也将具有不同的颜色。无论如何,效果如下所示:

第五章 玩家基本功能 - 图11


接下来是玩家死亡效果。我们要做的第一件事是将Projectiledie函数复制过来,当玩家碰到屏幕(可玩区域)的边缘,将其dead属性设置为true。完成之后,我们可以为其制作一些视觉效果。玩家死亡的主要视觉效果是一堆称为ExplodeParticle的粒子。看起来像爆炸,但并不是真的。通常,粒子是从其初始位置向一个随机方向移动并逐渐减小自身长度的线。一种可行的方法如下:

  1. function ExplodeParticle:new(area, x, y, opts)
  2. ExplodeParticle.super.new(self, area, x, y, opts)
  3. self.color = opts.color or default_color
  4. self.r = random(0, 2*math.pi)
  5. self.s = opts.s or random(2, 3)
  6. self.v = opts.v or random(75, 150)
  7. self.line_width = 2
  8. self.timer:tween(opts.d or random(0.3, 0.5), self, {s = 0, v = 0, line_width = 0},
  9. 'linear', function() self.dead = true end)
  10. end

在这里,我们定义了一些属性,其中大多数是自解释的(通过属性名可以看出其作用)。我们要做的另一件事是,在 0.3 到 0.5 秒的随机秒数内,将粒子的大小、速度和线宽插值至 0,并且在完成补间后,将粒子标记为死亡。粒子的运动代码与Projectile以及Player相似,因此我将跳过它。它仅使用一个给定角度的速度。

最后将粒子绘制为一条线:

  1. function ExplodeParticle:draw()
  2. pushRotate(self.x, self.y, self.r)
  3. love.graphics.setLineWidth(self.line_width)
  4. love.graphics.setColor(self.color)
  5. love.graphics.line(self.x - self.s, self.y, self.x + self.s, self.y)
  6. love.graphics.setColor(255, 255, 255)
  7. love.graphics.setLineWidth(1)
  8. love.graphics.pop()
  9. end
  10. `

通常,每当你需要绘制旋转的对象(在当前情况下,是粒子的速度方向)时,绘制就好像它是在角度 0(指向右侧)上一样。因此,在这种情况下,我们必须从左到右绘制直线,中心是旋转位置。所以s实际上是线长的一半。我们使用love.graphics.setLineWidth,使得该线在开始时较粗,随着时间的推移逐渐变细。

创建这些粒子的方式非常简单。只需在die函数上创建随机个数即可:

  1. function Player:die()
  2. self.dead = true
  3. for i = 1, love.math.random(8, 12) do
  4. self.area:addGameObject('ExplodeParticle', self.x, self.y)
  5. end
  6. end

可以做的最后一件事是绑定一个按键以触发Playerdie函数,因为在屏幕边缘不太方便确认效果是否正确:

  1. function Player:new(...)
  2. ...
  3. input:bind('f4', function() self:die() end)
  4. end

看起来如下所示:

第五章 玩家基本功能 - 图12

不过,这个效果看起来并没有那么戏剧化。真正能让这个效果看起来更戏剧化的一种方式是将时间减慢。这一点很多人都没有注意到,不过现在再重新审视很多游戏,你会发现当你被击中或死亡时,游戏的运行速度都会降低(即时间流逝减慢了)。一个很好的例子是 Downwell,这个视频展示了它的玩法,我标记了玩家被击的时间,这样方便你注意到刚才讨论的问题。

译注:国内朋友可以通过这个视频 2分30秒进行查看

做到这一点很容易。首先,我们可以在love.load中定义一个全局变量slow_amount,并将其默认值设置为1。接着,我们所有需要deltaTime为参数的update方法,都要乘上这个变量。因此,当我们希望将当前游戏时间减慢 50% 时,我们就可以将slow_amount设置为 0.5。代码看起来如下所示:

  1. function love.update(dt)
  2. timer:update(dt*slow_amount)
  3. camera:update(dt*slow_amount)
  4. if current_room then current_room:update(dt*slow_amount) end
  5. end

然后,我们需要定义一个函数来完成上述工作。通常,我们希望时间经过一小段后才能恢复正常,因此,我们额外添加一个duration参数,表示持续时间的长短:

  1. function slow(amount, duration)
  2. slow_amount = amount
  3. timer:tween('slow', duration, _G, {slow_amount = 1}, 'in-out-cubic')
  4. end

至此,调用slow(0.5, 1)意味着游戏将减慢到 50% 的速度,然后在 1 秒后重新回到全速。这里可以注意到,tween函数第一个参数为slow字符串,这意味着当slow正在运行但又再次被调用时,前一次tween将被取消,新的tween将生效,从而避免同时存在多个slow生效而导致的问题。

如果我们在玩家死亡时调用slow(0.15, 1),那么效果就将变成下面这样:

第五章 玩家基本功能 - 图13

除此之外,我们还可以为此添加屏幕震动。相机模块已经有一个shake函数来实现此效果,因此我们可以添加以下代码:

  1. function Player:die()
  2. ...
  3. camera:shake(6, 60, 0.4)
  4. ...
  5. end

最后,我们可以做的另一件事是使屏幕闪烁几帧。这也是很多游戏惯用的技巧,不过你可能从未注意到过,但是它确实可以提升效果。这是一个相当简单的效果:每当我们调用flash(n)时,屏幕就会以背景颜色闪烁 n 帧。实现该效果的一种方法是在love.load中定义一个flash_frames全局变量,并将其初始化为 nil,表示当前效果未生效。falsh 函数如下所示:

  1. function flash(frames)
  2. flash_frames = frames
  3. end

然后,我们修改love.draw函数:

  1. function love.draw()
  2. if current_room then current_room:draw() end
  3. if flash_frames then
  4. flash_frames = flash_frames - 1
  5. if flash_frames == -1 then flash_frames = nil end
  6. end
  7. if flash_frames then
  8. love.graphics.setColor(background_color)
  9. love.graphics.rectangle('fill', 0, 0, sx*gw, sy*gh)
  10. love.graphics.setColor(255, 255, 255)
  11. end
  12. end

首先,我们将flash_frames每帧减少 1,直到其减为 -1,则将其设置为 nil,表示效果结束。然后,只要当前效果还没有结束,我们就简单地绘制一个全屏矩形覆盖上去,其颜色为background_color。当我们将其添加到die函数中:

  1. function Player:die()
  2. self.dead = true
  3. flash(4)
  4. camera:shake(6, 60, 0.4)
  5. slow(0.15, 1)
  6. for i = 1, love.math.random(8, 12) do
  7. self.area:addGameObject('ExplodeParticle', self.x, self.y)
  8. end
  9. end

效果如下:

第五章 玩家基本功能 - 图14

效果非常微妙,几乎注意不到,但是像这样的小细节能使游戏更具吸引力。

玩家/弹幕死亡练习

  1. 不使用firstsecond属性,仅仅使用current_color属性,如何实现修改 ProjectDeathEffect 对象的颜色?

  2. 修改flash函数,使其接受一个持续时间(单位秒)而不是持续帧数。哪一个效果更好?或者说这只是一个偏好问题?timer 模块是否可以使用帧数而不是秒数来作为它的持续时间?