第五章 玩家基本功能
原文:https://github.com/a327ex/blog/issues/20
简介
在本章中,我们将重点为Player
类添加更多功能。首先,我们将重点放在玩家的攻击和发射子弹对象上。之后,我们将重点关注玩家的 2 个主要特性:加速(Boost)效果和更新(Cycle/Tick)效果。最后,我们将添加一个视觉效果完全不同的飞船到游戏中。从这一章开始,我们将只关注游戏性方面的内容,而前五章主要是基础建设(可以适用于任意游戏)。
玩家攻击
在此游戏中,玩家攻击的方式是:每隔 n 秒就会触发一次自动攻击。最终我们将拥有 16 中攻击类型,但几乎所有的攻击方式都是朝着玩家面对的方向发射不同的子弹。例如,下面是发射追踪导弹的效果:
下面这个尽管设计速度更快,但发射的角度有些随机:
虽然攻击和发射的子弹具有各种不同的属性,并且它们会受到不同事物的影响,但其核心逻辑始终是相同的。
为了达到上述效果,实现我们需要实现玩家每隔 n 秒攻击的逻辑。n 是一个根据攻击而变化的数字,默认值为 0.24。使用前面介绍的 Timer 库,我们可以轻松做到这一点:
function Player:new()
...
self.timer:every(0.24, function()
self:shoot()
end)
end
添加上述代码后,我们将每隔 0.24 秒调用一次Shoot
函数,我们将在该函数内添加实际创建子弹对象的代码。
现在,我们可以来设计Shoot
函数内的逻辑了。首先,对于每一次设计,我们都将产生一个很小的视觉效果,用来表示当前射击了。我有一个好的经验法则是:每当实体对象在游戏中被创建或删除时,都为其添加一个伴随的视觉效果,这样掩盖了实体对象在屏幕上凭空出现和消失的事实,并且通常会使游戏感觉更棒。
要创建这个效果,首先我们需要创建一个名为ShootEffect
的类(现在的你应该知道该如何创建)。这个效果只是在将要创建子弹的位置生成一个持续非常短时间的正方形。最简单的实现方法如下:
function Player:shoot()
self.area:addGameObject('ShootEffect', self.x + 1.2*self.w*math.cos(self.r),
self.y + 1.2*self.w*math.sin(self.r))
end
function ShootEffect:new(...)
...
self.w = 8
self.timer:tween(0.1, self, {w = 0}, 'in-out-cubic', function() self.dead = true end)
end
function ShootEffect:draw()
love.graphics.setColor(default_color)
love.graphics.rectangle('fill', self.x - self.w/2, self.y - self.w/2, self.w, self.w)
end
看起来如下所示:
上述效果代码非常直接。它只是一个变长为 8 的正方形,生命周期为 0.1 秒,并且在生命周期内,边长逐渐变为 0。现在有一个问题是,这个特效的位置静态的,它不会随着玩家的移动而移动。这看起来只是一个小细节,因为这个特效持续的时间非常短,不过一旦你把持续时间调整成 0.5 秒或更长时,你就会明白我说的问题了。
解决此问题的一种方法是将Player
对象作为参数传递给ShootEffect
对象,这样就可以通过下面这种方式来将ShootEffect
的位置同步到Player
对象了:
function Player:shoot()
local d = 1.2*self.w
self.area:addGameObject('ShootEffect',
self.x + d*math.cos(self.r),
self.y + d*math.sin(self.r),
{player = self, d = d})
end
```lua
function ShootEffect:update(dt)
ShootEffect.super.update(self, dt)
if self.player then
self.x = self.player.x + self.d*math.cos(self.player.r)
self.y = self.player.y + self.d*math.sin(self.player.r)
end
end
function ShootEffect:draw()
pushRotate(self.x, self.y, self.player.r + math.pi/4)
love.graphics.setColor(default_color)
love.graphics.rectangle('fill', self.x - self.w/2, self.y - self.w/2, self.w, self.w)
love.graphics.pop()
end
通过opts
表在玩家射击时,将ShootEffect
对象的player
属性设置为self
。这意味着,在ShootEffect
对象中,可以通过self.player
来访问到对应的Player
对象。通常,这便是我们将一个对象引用传递给另一个对象的方式,因为大部分情况下,我们都是通过某个对象的某个方法来创建另一个对象,此时self
就是我们想传递的变量。此外,我们还设置了一个d
属性,它表示我们希望攻击效果出现的位置离玩家中心的距离,也是通过opts
表来完成传递的。
然后在ShootEffect
的update
函数中,我们将其位置根据玩家的位置进行设置。务必始终检查要访问的引用是否被正确设置(通过if self.player then
),因为如果没有正确设置引用,就会产生错误。很多时候,随着我们的代码量越来越大,会有很多对象在被其他对象引用时死亡,我们仍然会去访问这些对象的某些字段,但由于这些对象已经死亡,很可能对应的字段已经被置空,这时就会产生错误。像这样互相引用对象时,请务必牢记这一点。
最后一个细节是,我使方块效果与玩家的角度同步,然后再将其旋转 45 度以使其看起来更酷。通过pushRotate
函数实现上述效果:
function pushRotate(x, y, r)
love.graphics.push()
love.graphics.translate(x, y)
love.graphics.rotate(r or 0)
love.graphics.translate(-x, -y)
end
这是一个将变换推入变换栈中的简单函数。实际上,它使所有接下来绘制的内容围绕x, y
位置旋转r
角度,直到我们调用love.graphics.pop
。在上面示例中,我们将正方形效果围绕其中心点旋转玩家当前的角度再加上 45 度(pi / 4 弧度)。为了完整起见,这里还提供了一个包含缩放的函数版本:
function pushRotateScale(x, y, r, sx, sy)
love.graphics.push()
love.graphics.translate(x, y)
love.graphics.rotate(r or 0)
love.graphics.scale(sx or 1, sy or sx or 1)
love.graphics.translate(-x, -y)
end
这些函数非常实用,还将在我们整个游戏中使用,因此请确保你会使用它们,并了解它们!
玩家攻击练习
如今,我们只需要在玩家的构造函数中使用默认构造的计时器,就可以每隔 0.24 秒调用一次射击函数。假设
Player
中存在一个self.attack_speed
属性,该属性每 5 秒变化成 1 ~ 2 中的一个随机值:function Player:new(...)
...
self.attack_speed = 1
self.timer:every(5, function() self.attack_speed = random(1, 2) end)
self.timer:every(0.24, function() self:shoot() end)
思考:如何修改
Player
逻辑,使其每隔0.24 / self.attack_speed
秒攻击一次?请注意,只是简单改一下every
函数的第一个参数,是无法达到目的的。在上一篇教程中,我们讨论了垃圾回收以及被遗忘的引用是多么危险且会导致内存泄漏问题。在本文中,我介绍了在
Player
和ShootEffect
实例中互相引用的示例。在该示例中,ShootEffect
是一个短生命周期的对象,其中包含了对Player
对象的引用,思考:我们是否需要关心显示取消对Player
的引用,以便垃圾回收器能正确地回收Player
对象?更一般的情况,什么情况下我们需要关心取消这样相互引用的对象?使用
pushRotate
将Player
对象绕其中心旋转 180 度。效果如下:使用
pushRotate
将指示玩家移动方向的线绕其中心旋转 90 度。效果如下:使用
pushRotate
将指示玩家移动方向的线绕玩家中心旋转 90 度。效果如下:使用
pushRotate
将射击特效绕玩家中心旋转 90 度。效果如下:
玩家弹幕
现在我们已经完成了射击特效,接下来我们将实现实际发射出去的子弹。子弹的运动机制与玩家的非常相似,它是一个具有一定初始角度的物理对象,然后我们将根据该角度设置其速度。让我们从shoot
函数内部开始:
function Player:shoot()
...
self.area:addGameObject('Projectile', self.x + 1.5*d*math.cos(self.r),
self.y + 1.5*d*math.sin(self.r), {r = self.r})
end
这些逻辑应该没有什么意外,我们使用上文提到的d
变量来设置子弹的初始位置,然后将玩家的角度作为r
属性进行传递。可以注意到,与ShootEffect
对象不同,子弹在创建时,除了玩家的角度,不需要其他任何信息,因此我们不需要传递Player
引用。
再来看一下子弹的构造函数。子弹对象将具有一个圆形碰撞体(像Player
那样)属性、一个速度属性和一个指示其移动方向的属性:
function Projectile:new(area, x, y, opts)
Projectile.super.new(self, area, x, y, opts)
self.s = opts.s or 2.5
self.v = opts.v or 200
self.collider = self.area.world:newCircleCollider(self.x, self.y, self.s)
self.collider:setObject(self)
self.collider:setLinearVelocity(self.v*math.cos(self.r), self.v*math.sin(self.r))
end
s
属性表示碰撞体的半径,这个属性名不是r
是因为已经有一个属性r
用来表示其移动的角度。通常,我会使用变量w
、h
、r
或s
来表示对象的尺寸。当对象是矩形时,我会使用前两个变量,当它是圆形时,我会使用后两个变量。如果r
变量被用于表示某个方向时(如本例),则会用s
表示半径。这些属性主要用于显示相关,因为大多数情况下,这些对象有对应的碰撞体来进行碰撞相关的工作。
这里我们所做的另一件事是,使用opts.attrbute or default_value
进行构造(相关知识点我想已经在其他章节中介绍了)。借由 Lua 中or
的工作方式,我们可以使用此写法来简化代码,逻辑等同于:
if opts.attribute then
self.attribute = opts.attribute
else
self.attribute = default_value
end
我们先检查属性是否存在,如果存在,则直接使用它,否则使用默认值。在我们的代码中,如果opts.s
存在,则将self.s
设置为其值,否则将其设置为 2.5。self.v
同理。最后,我们使用setLinearVelocity
来设置子弹的速度,其参数来自子弹的初始速度和从Player
传入的角度。这里使用了与Player
一样的移动逻辑,因此你应该可以理解它。
如果我们现在更新并绘制子弹,例如:
function Projectile:update(dt)
Projectile.super.update(self, dt)
self.collider:setLinearVelocity(self.v*math.cos(self.r), self.v*math.sin(self.r))
end
function Projectile:draw()
love.graphics.setColor(default_color)
love.graphics.circle('line', self.x, self.y, self.s)
end
看起来如下:
玩家弹幕练习
进入玩家射击函数,将创建出的弹幕尺寸(半径)改为 5,速度改为 150。
修改玩家射击函数,使其同时发射 3 个子弹,其中 2 个子弹发射的角度为玩家当前角度 +-30 度,看起来如下:
修改玩家射击函数,使其同时发射 3 个子弹,其中两边的 2 个子弹发射的位置为中间的子弹向两边偏移 8 个像素,看起来如下:
修改子弹初始速度为 100,当其创建出来后,在 0.5 秒内加速到 400。
玩家及弹幕死亡
既然玩家目前可以实现基本的移动和攻击,我们就可以开始思考游戏的一些附加规则了。其中之一是,如果玩家碰到游戏游玩区域的边界,他将死亡。弹幕也是如此,因为现在它们只有生成逻辑,但是从来不会死亡,随着它们数量越来越多,游戏的性能也会随之大大降低。
因此,让我们从Projectile
对象开始:
function Projectile:update(dt)
...
if self.x < 0 then self:die() end
if self.y < 0 then self:die() end
if self.x > gw then self:die() end
if self.y > gh then self:die() end
end
我们知道,游戏可玩区域的中心是(gw/2, gh/2)
,左上角是(0, 0)
点,右下角是(gw, gh)
点。因此我们要做的就是向抛射物的update
函数中新增一系列条件判断,来检查其当前位置是否超出了我们游戏可玩区域的边界,如果是,我们将调用die
函数。
对于Player
对象来说也是如此:
function Player:update(dt)
...
if self.x < 0 then self:die() end
if self.y < 0 then self:die() end
if self.x > gw then self:die() end
if self.y > gh then self:die() end
end
接下来我们看一下die
函数。这个函数非常简单直接,他就是将当前实体对象的dead
属性设置为true
,并产生一些视觉效果。对抛射物来说,产生的效果对应生成的对象是ProjectileDeadEffect
,就像ShootEffect
那样,它的形状是一个正方形,持续存在一段时间后便消失,不过具体效果还是有一些差异。最大的差异是ProjectileDeadEffect
将闪烁一会,然后再恢复为正常颜色,最后消失。在我看来,这是一个微妙但不错的弹出效果。其构造函数看起来如下所示:
function ProjectileDeathEffect:new(area, x, y, opts)
ProjectileDeathEffect.super.new(self, area, x, y, opts)
self.first = true
self.timer:after(0.1, function()
self.first = false
self.second = true
self.timer:after(0.15, function()
self.second = false
self.dead = true
end)
end)
end
我们定义了两个属性,first
和second
,它们将表示效果当前处于哪个阶段。如果在第一阶段,它的颜色将是白色,而在第二阶段,它的颜色将变成它应该显示的颜色。完成第二阶段后,效果通过将其dead
属性设置为true
从而消失。这一切都放生在 0.25 秒(0.1 + 0.15)的时间范围内,因此这是一个短暂且快速的效果。下面来看看该特效如何绘制到屏幕上,它的绘制方式与ShootEffect
的非常相似:
function ProjectileDeathEffect:draw()
if self.first then love.graphics.setColor(default_color)
elseif self.second then love.graphics.setColor(self.color) end
love.graphics.rectangle('fill', self.x - self.w/2, self.y - self.w/2, self.w, self.w)
end
正如我上文所解释的那样,这里我们仅仅根据不同的阶段来绘制不同颜色的矩形。我们将从Projectile
对象的die
函数中将其创建:
function Projectile:die()
self.dead = true
self.area:addGameObject('ProjectileDeathEffect', self.x, self.y,
{color = hp_color, w = 3*self.s})
end
有一件我之前未提到的事情,该游戏将使用有限数量的颜色。我不是一名艺术家,我不想花太多时间来纠结选择哪种颜色,所以我只挑选了一些色彩搭配得很好的颜色,并在游戏各个地方使用它们。这些颜色在globals.lua
中定义,如下所示:
default_color = {222, 222, 222}
background_color = {16, 16, 16}
ammo_color = {123, 200, 164}
boost_color = {76, 195, 217}
hp_color = {241, 103, 69}
skill_point_color = {255, 198, 93}
对于ProjectileDeathEffect
,我选择使用hp_color
(红色)。不过未来应该将其修改为弹幕的颜色。不同的攻击类型有不同的颜色,因此弹幕死亡效果也将具有不同的颜色。无论如何,效果如下所示:
接下来是玩家死亡效果。我们要做的第一件事是将Projectile
的die
函数复制过来,当玩家碰到屏幕(可玩区域)的边缘,将其dead
属性设置为true
。完成之后,我们可以为其制作一些视觉效果。玩家死亡的主要视觉效果是一堆称为ExplodeParticle
的粒子。看起来像爆炸,但并不是真的。通常,粒子是从其初始位置向一个随机方向移动并逐渐减小自身长度的线。一种可行的方法如下:
function ExplodeParticle:new(area, x, y, opts)
ExplodeParticle.super.new(self, area, x, y, opts)
self.color = opts.color or default_color
self.r = random(0, 2*math.pi)
self.s = opts.s or random(2, 3)
self.v = opts.v or random(75, 150)
self.line_width = 2
self.timer:tween(opts.d or random(0.3, 0.5), self, {s = 0, v = 0, line_width = 0},
'linear', function() self.dead = true end)
end
在这里,我们定义了一些属性,其中大多数是自解释的(通过属性名可以看出其作用)。我们要做的另一件事是,在 0.3 到 0.5 秒的随机秒数内,将粒子的大小、速度和线宽插值至 0,并且在完成补间后,将粒子标记为死亡。粒子的运动代码与Projectile
以及Player
相似,因此我将跳过它。它仅使用一个给定角度的速度。
最后将粒子绘制为一条线:
function ExplodeParticle:draw()
pushRotate(self.x, self.y, self.r)
love.graphics.setLineWidth(self.line_width)
love.graphics.setColor(self.color)
love.graphics.line(self.x - self.s, self.y, self.x + self.s, self.y)
love.graphics.setColor(255, 255, 255)
love.graphics.setLineWidth(1)
love.graphics.pop()
end
`
通常,每当你需要绘制旋转的对象(在当前情况下,是粒子的速度方向)时,绘制就好像它是在角度 0(指向右侧)上一样。因此,在这种情况下,我们必须从左到右绘制直线,中心是旋转位置。所以s
实际上是线长的一半。我们使用love.graphics.setLineWidth
,使得该线在开始时较粗,随着时间的推移逐渐变细。
创建这些粒子的方式非常简单。只需在die
函数上创建随机个数即可:
function Player:die()
self.dead = true
for i = 1, love.math.random(8, 12) do
self.area:addGameObject('ExplodeParticle', self.x, self.y)
end
end
可以做的最后一件事是绑定一个按键以触发Player
的die
函数,因为在屏幕边缘不太方便确认效果是否正确:
function Player:new(...)
...
input:bind('f4', function() self:die() end)
end
看起来如下所示:
不过,这个效果看起来并没有那么戏剧化。真正能让这个效果看起来更戏剧化的一种方式是将时间减慢。这一点很多人都没有注意到,不过现在再重新审视很多游戏,你会发现当你被击中或死亡时,游戏的运行速度都会降低(即时间流逝减慢了)。一个很好的例子是 Downwell,这个视频展示了它的玩法,我标记了玩家被击的时间,这样方便你注意到刚才讨论的问题。
译注:国内朋友可以通过这个视频 2分30秒进行查看
做到这一点很容易。首先,我们可以在love.load
中定义一个全局变量slow_amount
,并将其默认值设置为1
。接着,我们所有需要deltaTime
为参数的update
方法,都要乘上这个变量。因此,当我们希望将当前游戏时间减慢 50% 时,我们就可以将slow_amount
设置为 0.5。代码看起来如下所示:
function love.update(dt)
timer:update(dt*slow_amount)
camera:update(dt*slow_amount)
if current_room then current_room:update(dt*slow_amount) end
end
然后,我们需要定义一个函数来完成上述工作。通常,我们希望时间经过一小段后才能恢复正常,因此,我们额外添加一个duration
参数,表示持续时间的长短:
function slow(amount, duration)
slow_amount = amount
timer:tween('slow', duration, _G, {slow_amount = 1}, 'in-out-cubic')
end
至此,调用slow(0.5, 1)
意味着游戏将减慢到 50% 的速度,然后在 1 秒后重新回到全速。这里可以注意到,tween
函数第一个参数为slow
字符串,这意味着当slow
正在运行但又再次被调用时,前一次tween
将被取消,新的tween
将生效,从而避免同时存在多个slow
生效而导致的问题。
如果我们在玩家死亡时调用slow(0.15, 1)
,那么效果就将变成下面这样:
除此之外,我们还可以为此添加屏幕震动。相机模块已经有一个shake
函数来实现此效果,因此我们可以添加以下代码:
function Player:die()
...
camera:shake(6, 60, 0.4)
...
end
最后,我们可以做的另一件事是使屏幕闪烁几帧。这也是很多游戏惯用的技巧,不过你可能从未注意到过,但是它确实可以提升效果。这是一个相当简单的效果:每当我们调用flash(n)
时,屏幕就会以背景颜色闪烁 n 帧。实现该效果的一种方法是在love.load
中定义一个flash_frames
全局变量,并将其初始化为 nil,表示当前效果未生效。falsh 函数如下所示:
function flash(frames)
flash_frames = frames
end
然后,我们修改love.draw
函数:
function love.draw()
if current_room then current_room:draw() end
if flash_frames then
flash_frames = flash_frames - 1
if flash_frames == -1 then flash_frames = nil end
end
if flash_frames then
love.graphics.setColor(background_color)
love.graphics.rectangle('fill', 0, 0, sx*gw, sy*gh)
love.graphics.setColor(255, 255, 255)
end
end
首先,我们将flash_frames
每帧减少 1,直到其减为 -1,则将其设置为 nil,表示效果结束。然后,只要当前效果还没有结束,我们就简单地绘制一个全屏矩形覆盖上去,其颜色为background_color
。当我们将其添加到die
函数中:
function Player:die()
self.dead = true
flash(4)
camera:shake(6, 60, 0.4)
slow(0.15, 1)
for i = 1, love.math.random(8, 12) do
self.area:addGameObject('ExplodeParticle', self.x, self.y)
end
end
效果如下:
效果非常微妙,几乎注意不到,但是像这样的小细节能使游戏更具吸引力。
玩家/弹幕死亡练习
不使用
first
和second
属性,仅仅使用current_color
属性,如何实现修改 ProjectDeathEffect 对象的颜色?修改
flash
函数,使其接受一个持续时间(单位秒)而不是持续帧数。哪一个效果更好?或者说这只是一个偏好问题?timer 模块是否可以使用帧数而不是秒数来作为它的持续时间?