用 LÖVE 实现帅气的 Downwell 式尾迹效果

作者:craft
2016-05-03
15 47 8

引言

这篇教程会教给大家如何用 LÖVE 来实现游戏 Downwell 中帅气的尾迹效果。
即便你对 LÖVE/lua 还所知甚少,但只要具备一定的编程基础知识,就应该能够看懂这篇教程。此外,把这篇教程当成学习和入门 LÖVE/lua 的教程与练习来阅读也完全不成问题。
本文中所用到的代码可以在这里查看。

dw-1

LÖVE

在着手实现尾迹效果前,我们得先编写一个简单的代码框架:大致上就是简单地实现一下实体的增删,屏幕绘制等基础功能。由于 LÖVE 并不内置这些功能,所以我们还是自己动手,丰衣足食吧。

首先,我们创建项目文件夹,并在项目文件夹下新建名为 main.lua 的文件,文件内容如下:

function love.load()

end

function love.update(dt)

end

function love.draw()

end

LÖVE 项目载入完成后,会在启动阶段运行一次 love.load() 接着在每一帧运行一次 love.update()love.draw()。你可以参考官方的入门教程来学习如何运行这个简单的 LÖVE 项目,不过运行上面的代码理论上你最后只能看到一个黑洞洞的窗口。

GameObject(游戏对象)

接下来,我们需要为游戏创建一些实体,为此,我们使用一个向 LÖVE 引入面向对象特性的代码库 classic:我们需要先下载库文件,然后将它放入与 main.lua 同路径下的 classic 文件夹下,接着在代码中引用它:

Object = require 'classic/classic'

fuction love.load()
...

Object 现在是一个存放着 classic 库定义的全局变量,通过它,我们能够轻松地创建新的类,譬如写成这样: Point = Object:extend()

我们用它来创建用于游戏实体的主类 GameObejct

-- in GameObject.lua
GameObject = Object:extend()

function GameObject:new()

end

function GameObject:update(dt)

end

function GameObject:draw()

end

由于我们将代码放在了单独的文件 GameObject.lua 中,因此我们需要在 main.lua 中引用这个文件,否则我们就无法访问 GameObject 类。

-- in main.lua
Object = require 'classic/classic'
require 'GameObject'

这样,GameObject 就是指向了 GameObject 类的全局变量。

抑或我们也可以将 GameObject 定义成 GameObject.lua 中的局部变量,然后再在 main.lua 中使其变成全局变量。

像下面这样写:

-- in GameObject.lua
local GameObject = Object:extend()

function GameObject:new()
...
...

return GameObject
-- in main.lua
Object = require 'classic/classic'
GameObject = require 'GameObject'  

有时候,尤其是在为他人编写库文件的时候,这种写法更好,因为你无需担心库文件中的变量污染全局变量。 classic 库就是这样做的,这也是为什么你需要将其赋值给一个全局变量来初始化它。这样做还有一个好处,如果我们使用 Class 而非 Object 作变量名,我们的类定义就可以写成像 GameObject = Class:extend() 这样的形式。

GameObject 还需要具备一些属性。我所有的对象构造函数参数都会按 ClassName(x, y, optionl_arguments) 的模式来写,这样既简单又实用:我发现所有的对象都可以拥有有一个坐标位置(即便那些不需要有位置的对象,也可以直接将参数写成 0, 0),而 optionl_arguments 则是一个具有多个可选参数(你完全可以按需添加)的 table。这样我们的 GameObejct 看上去是这个模样:

-- in GameObject.lua
local GameObject = Object:extend()

function GameObject:new(x, y, opts)
  self.x, self.y = x, y
  local opts = opts or {} -- 用来处理 opts 为空的情况,常用小技巧
  for k, v in pairs(opts) do self[k] = v end
end
-- in main.lua
...
function love.load()
  game_object = GameObject(100, 100, {foo = 1, bar = true, baz = 'hue'})
  print(game_object.x, game_object.foo, game_object.bar, game_object.baz)
end

在本例中,我们创建了对象 GameObject 的实例 game_object, 它具有名为 foobarbaz 的属性。我曾撰文介绍过如何在构造函数中用 for 来实现参数列表,你可以参看这篇文章Object,attributes and methods in Lua 部分。

你可能留意到我使用了 print 函数,它真的非常好用的调试工具,为了发挥它的威力,你需要用控制台来启动游戏。如果你使用的是 Windows 操作系统,可以考虑为项目增加 --console 的启动参数,这样游戏启动时会同时打开一个命令行窗口,这样你就能用 print 函数显示你想要调试的状态信息。print 输出的信息不会出现在游戏画面上,老实说,在游戏画面上显示调试游戏真的是一种非常痛苦的体验,所以我强烈推荐你采用这种启动游戏时同时显示命令行窗口的调试方式。

对象的创建与删除

尾迹的效果是通过快速创建与删除多个尾迹对象实例(譬如在生成 0.2s 后就删除掉实例)来实现的,因此我们需要实现创建和删除对象的逻辑功能。对象创建的方法参见上一节,我们现在需要将所有创建好的对象放入一个 table 中(table.insert):

function love.load()
  game_objects = {}
  createGameObject(100, 100)
end

function love.update(dt)
  for _, game_object in ipairs(game_objects) do
    game_object:update(dt)
  end
end

function love.draw()
  for _, game_object in ipairs(game_objects) do
    game_object:draw()
  end
end

function createGameObject(x, y, opts)
  local game_object = GameObject(x, y, opts)
  table.insert(game_objects, game_object)
  return game_object -- 返回实例,以备他处使用
end

这样我写好了一个名为 createGameObject 的函数,它会创建一个新的 GameObject 实例并将它添加到了 game_objects 中。我们会通过 love.updatelove.draw 每帧更新和绘制所有出现在表中的对象实例。我们来为其编写具体的绘制函数,本例中我使用了内置的 love.graphic.circle 方法:

function GameObject:draw()
  love.graphics.circle('fill', self.x, self.y, 25)
end

为了能够删除实例,我们还需要做一些额外工作。首先,我们为所有的 GameObject 添加一个新的成员变量 dead,这个布尔变量用来标记该对象的实例状态是否还存货:

function GameObject:new(x, y, opts)
  ...
  self.dead = false
end

接下来,我们稍微改动被标记死亡的对象实例的更新逻辑,确保将它们移除出 game_objects(table.remove):

function love.update(dt)
  for i = #game_objects, 1, -1 do
    local game_object = game_objects[i]
    game_object:update(dt)
    if game_object.dead then table.remove(game_objects, i) end
  end
end

这样,当 GameObject 实例的 dead 属性为 true 时,游戏会自动将其从 game_objects 中删除。这段代码中有一点特别值得注意,我们是逆向遍历 table 的。这是因为在 lua 中,如果删除 table 中的成员它就会重新生成序列,以保证不会出现 nil。这就意味着,如果对象 12 需要被删掉时,如果按顺序遍历 table,我们在删掉对象 1 后对象 2 会变成新的对象 1,导致我们最后实际删掉的是对象 1 和对象 2 后面的对象 3。为避免出现这种问题,我们需要逆序遍历 table(凡是进行了影响 table 序列的操作都当如此)。

为了测试上述代码是否奏效,我们可以增加鼠标点击来删除实例的方法 (love.mousepressed) 来测试:

function love.load()
  game_objects = {}
  game_object = createGameObject(100, 100)
end

...

function love.mousepressed(x, y, button)
  if button == 1 then -- 1 = 左击

    game_object.dead = true
  end
end

为了便于测试后面的效果,我们还可以将游戏对象实例的位置绑定到鼠标位置(love.mouse.getPosition)上:

function GameObject:update(dt)
  self.x, self.y = love.mouse.getPosition()
end

屏幕分辨率

在我们开始捣鼓尾迹效果,享受其中乐趣前让我们搞定最后一个障碍。像 Downwell 这样的游戏原始分辨率非常低,画面会拉伸为整个屏幕大小,这营造出一种养眼的像素风格效果。 LÖVE 游戏默认使用 800x600 的分辨率,对我们来说稍微嫌大,我们需要在 conf.lua 文件中将其减少为 320x240。因此,我们在 main.lua 同路径下创建 conf.lua 文件,并编写如下内容:

function love.conf(t)
    t.window.width = 320
    t.window.height = 240
end

这样一来,如果我们需要将其放大,比方说放大到 960x720 (即放大 3 倍),我们可以将整个屏幕绘制到一张画布 canvas(参见官方 wiki 相关章节) 上,然后将 canvas 放大三倍。我们会一直使用原始分辨率来编写游戏逻辑,直到绘制时再将其放大(love.graphics.newCanvas):

function love.load()
  ...
  main_canvas = love.graphics.newCanvas(320, 240)
  main_canvas:setFilter('nearest', 'nearest')
end

function love.draw()
  love.graphics.setCanvas(main_canvas)
  love.graphics.clear()
  for _, game_object in ipairs(game_objects) do
      game_object:draw()
  end
  love.graphics.setCanvas()

  love.graphics.draw(main_canvas, 0, 0, 0, 3, 3)
end

首先,我们创建一张为始分辨率大小的新画布 main_canvas,并将它的 filter 设置为 nearest(这样它会按最近邻算法来缩放图像,这可以保证原汁原味的像素风格)。在绘制函数中,我们不再仅仅简单地绘制对象实例,而是先通过 love.graphics.setCanvas 来设置画布为 main_canvas ,用 love.graphics.clear 清除上一帧的内容,然后再绘制游戏对象,在重设画布后以3倍大小绘制 main_canvas 画布。效果如下:

dw-2

对比之前测试中的效果,像素风格得以完美呈现,看起来一切正常。如果你希望游戏窗口能更大一些,可以使用 love.window.setMode 方法:

function love.load()
  ...
  love.window.setMode(960, 720)
end

如果你遵循上面的指导一步一步做到这里,可能会注意到绑定鼠标位置的代码好像出问题了。这是因为 love.mouse.getPostion 方法是基于屏幕大小来实现的,因此如果鼠标位于现在的屏幕中心,坐标为 480, 360 的位置,超过了 320, 240 的范围, 就不会再往屏幕上画圆了。简单来说,这是因为我们使用的画布大小为 320.240, 而屏幕范围远比这大。为了真正解决这个问题我们需要编写一套摄像机系统,但这个例子中我们采用捷径,简单地将鼠标位置除以 3 临时对付一下:

function GameObject:update(dt)
  local x, y = love.mouse.getPosition()
  self.x, self.y = x/3, y/3
end

计时器与多对象类型

现在万事俱备,只欠东风,让我们正式开始编写尾迹效果吧。

稍等...我其实撒了谎。

我们还需要一个计时器的库。因为我们需要有一种简单的方法来实现创建 n 秒后删除一个实例的功能,还需要一种简单的方法能够补间对象实例的状态(尾迹系统不就是干这事儿的么)。为此,我使用了另一个 LÖVE 库:HUMP。类似 classic 库,我们需要将库文件下载后放入项目文件夹,并在代码中 require 它的计时器模块:

Object = require 'classic/classic'
GameObject = require 'GameObject'
Timer = require 'hump/timer'

现在我们有计时器可用了。我认为为每个需要计时器的对象创建单独的计时器更好,但本例中,我们就使用一个全局的计时器:

function love.load()
  timer = Timer()
  ...
end

function love.update(dt)
  timer.update(dt)
  ...
end

我们可以调用计时器的一个函数 after 来测试它是否已经正常工作。这个函数接受参数 n 然后在 n 秒后执行作为第二个参数的函数:

function love.load()
  timer = Timer()
  timer.after(4, function() game_object.dead = true end)
  ...
end

如上代码的效果为,游戏运行 4 秒后 game_object 就会被删掉。


接下来我们来定义多对象类型,这回真的是实现尾迹效果前的最后一件准备工作了。每个尾迹对象实例(它们很快就会被删掉)都需要是某个类的实例,但肯定不能是 GameObject 类,因为我们已经为其编写了跟随鼠标和绘圆的功能。因此,我们需要让它支持多对象类型:

function createGameObject(type, x, y, opts)
  local game_object = _G[type](x, y, opts)
  table.insert(game_objects, game_object)
  return game_object -- 返回实例,以备他处使用
end

整个函数中我们所做的改变仅仅是接受 type 参数并使用它作为下标访问全局变量 _G_G 是 Lua 中存放所有全局变量的 table。由于它就只是 table 而已,所以你可以使用合适的 key 来访问其中变量的值, 而 key 恰好就是这些变量的变量名。因此, _G['game_object'] 就是我们目前正在使用的 GameObject 实例,而 _G['GameObject'] 就是 GameObject 的类定义。因此,我们的构造函数参数列表变成了这样: createGameObject(class_name, x, y, opts),创建 GameObject 实例的方法则变成了这样:

function love.load()
  game_object = createGameObject('GameObject', 100, 100)
  ...
end

现在,当我们创建尾迹对象时,只需要改变传入 createGameObject 的第一个参数就能避免创建出 GameObject 实例了。

尾迹

现在,开始制作尾迹效果吧。我这里介绍的方法其实非常简单。首先,你需要有能够发射尾迹效果的对象实例,你需要每隔 n 秒(比如说 0.2 秒)以你希望的样子创建一个尾迹对象实例。Trail 对象应该很快(创建后经过 0.2s)就会被删掉,否则你会拖着一个相当长的尾迹。这样,准备工作已经妥当,我们来创建 Trail 类吧:

-- in Trail.lua
local Trail = Object:extend()

function Trail:new(x, y, opts)
  self.dead = false
  self.x, self.y = x, y
  local opts = opts or {}
  for k, v in pairs(opts) do self[k] = v end

  timer.after(0.15, function() self.dead = true end)
end

function Trail:update(dt)

end

function Trail:draw()
  love.graphics.circle('fill', self.x, self.y, self.r)
end

return Trail

Trail 的构造函数的前 4 行和 GameObject 没什么区别,只是普通的对象属性,接下来的那行代码十分关键,它使用了计时器,这样 0.15s 后它会调用一个将 dead 标志变量置为真的匿名函数。在绘制函数中,它也只是简单地画圆,并且使用 self.r 作为圆的半径。这个属性我们稍后会在调用时指定(通过 opts 参数),这样,构造函数方面的工作就完成了。

接下来,我们来创建尾迹。我们在 GameObject 的构造函数里做这件事。每隔 n 秒我们都会在当前 game_object 所在位置创建一个新的 Trail 实例:

function GameObject:new(x, y, opts)
  ...
  timer.every(0.01, function() createGameObject('Trail', self.x, self.y, {r = 25}) end)
end

这样每一帧或者每隔 0.01s 我们都会以半径 r = 25self.x, self.y 位置绘制圆形的尾迹。你需要注意的是,函数中的 self.x, self.y 会更新到当前位置而非仅仅是构造函数中所使用的值。这是因为这个函数每一帧都会被调用,由于 lua 中闭包的工作方式,这个函数都能访问到 self ,而 self 总会更新。

具体的效果则如下:
dw-3

Downwell 式尾迹效果

为制造出类似 Downwell 中的残影尾迹效果,我们需要从尾迹中擦除掉一些或水平或竖直的线条,由于只需要在一部分类型的游戏对象上产生这个效果,因此我们应当在不同的画布上分别绘制主要的游戏对象与尾迹。我们首先需要有区别地绘制不同类的不同对象,目前我们还没有实现这个功能。classic 库创建的类实体并不会自动拥有标识其所属类的成员变量,因此我们需要手动添加。

-- in main.lua
function createGameObject(type, x, y, opts)
  local game_object = _G[type](type, x, y, opts)
  ...
end
-- in GameObject.lua
function GameObject:new(type, x, y, opts)
  self.type = type
  ...
end
-- in Trail.lua
function Trail:new(type, x, y, opts)
  self.type = type
  ...
end

这里我们所做的工作不过是为所有的对象添加类 type 成员变量,这样我们就能使用诸如 object.type == 'Trail' 这样的判断语句来确认当前对象的类型了。

在分别将对象绘制到不同画布前,我们还需要创建它们:

function love.load()
  ...
  game_object_canvas = love.graphics.newCanvas(320, 240)
  game_object_canvas:setFilter('nearest', 'nearest')
  trail_canvas = love.graphics.newCanvas(320, 240)
  trail_canvas:setFilter('nearest', 'nearest')
end

创建这些画布调用的方法一如之前创建 main_canvas 时的步骤。现在,我们将尾迹对象绘制到 trail_canvas 上,将游戏对象绘制到 game_object_canvas 上,再将它们都绘制到 main_canvas 上,并将 main_canvas 拉伸后绘制到屏幕上。代码如下:

function love.draw()
  love.graphics.setCanvas(trail_canvas)
  love.graphics.clear()
  for _, game_object in ipairs(game_objects) do
    if game_object.type == 'Trail' then
      game_object:draw()
    end
  end
  love.graphics.setCanvas()

  love.graphics.setCanvas(game_object_canvas)
  love.graphics.clear()
  for _, game_object in ipairs(game_objects) do
    if game_object.type == 'GameObject' then
      game_object:draw()
    end
  end
  love.graphics.setCanvas()

  love.graphics.setCanvas(main_canvas)
  love.graphics.clear()
  love.graphics.draw(trail_canvas, 0, 0)
  love.graphics.draw(game_object_canvas, 0, 0)
  love.graphics.setCanvas()

  love.graphics.draw(main_canvas, 0, 0, 0, 3, 3)
end

看得出来,写法有些愚蠢,比方说我们居然遍历了两次游戏对象列表(虽然我们不至于绘制两次它们)。的确还有很多优化的空间,但作为一个规模不大的示例先优化放一边也没问题,重要的是先把功能实现好!

搞定独立渲染绘制不同游戏对象的功能后我们继续研究尾迹效果。为了让所有的尾迹效果上都出现栅栏条纹,我们可以通过 subtract 混合模式(blend mode) 直接擦除掉尾迹画布上的一些线条。该混合模式正如其名,可以用来弱化(substract)画布和屏幕上已经绘制出的一些东西。本例中,我们想以 subtract 模式往 trail_canvas (此时尾迹已经画好)上绘制一些白线(love.graphics.setBlendMode):

function love.load()
  ...
  love.graphics.setLineStyle('rough')
end

function love.draw()
  love.graphics.setCanvas(trail_canvas)
  love.graphics.clear()
  for _, game_object in ipairs(game_objects) do
    if game_object.type == 'Trail' then
      game_object:draw() -- 只绘制尾迹对象
    end
  end

  love.graphics.setBlendMode('subtract')
  for i = 0, 360, 2 do
    love.graphics.line(i, 0, i, 240) -- 绘制端点位置分别为 (i, 0) 和 (i, 240) 的线段
  end
  love.graphics.setBlendMode('alpha')
  love.graphics.setCanvas()

  ...
end

效果如下:

dw-4

值得注意的是,在绘制线条前,我调用 love.graphics.setLineStyle 方法将线条样式设置为了 'rough',这是因为默认的 'smooth' 样式并不贴合像素的画面风格。如果你还对此毫无概念的话,下图即为默认状态下的绘制效果:

dw-5

总而言之,我们只需要从尾迹画布中弱化一些线条即可达成效果,这些效果也只会影响尾迹画布上绘制的尾迹效果。另外需要特别注意的是,subtract 混合模式只适合用于黑色背景的游戏。我在我的一个游戏中尝试实现同样效果,最后看上去是这个样子:

dw-6

实际上我这里没有使用 substract 混合模式,而是使用了 multiply,前者效果比上图还要糟糕得多。而 multply, 如其名所示,能够强化某些像素的颜色而非弱化它们。因此,往画布上绘制的白线不会显示出来,但画布与线条间隙重合的部分却会变成透明的。使用这种方法想要获得同样的条纹效果,你需要的不是弱化白色线条自己来得到透明线条,而是强化线条间隙来得到条纹。

细节调整

简单的尾迹效果已经实现,但它还可以变得比现在更有趣。

我们首先随机绘制一些线条,这样最终效果中就会出现一些随机的纹路:

function love.load()
  ...
  trail_lines_extra_draw = {}
  timer.every(0.1, function()
    for i = 0, 360, 2 do
      if love.math.random(1, 10) >= 2 then trail_lines_extra_draw[i] = false
      else trail_lines_extra_draw[i] = true end
    end
  end)
end

function love.draw()
  ...
  love.graphics.setBlendMode('subtract')
  for i = 0, 360, 2 do
    love.graphics.line(i, 0, i, 240)
    if trail_lines_extra_draw[i] then
      love.graphics.line(i+1, 0, i+1, 240)
    end
  end
  love.graphics.setBlendMode('alpha')
  ...
end

这样在绘制线条时,每隔 0.1s,我们将 trail_lines_extra_draw 这张 table 中的一些布尔量设置为 true 或者 false, 这样当我们在绘制当前那条线时,会在其右侧一像素的位置再额外绘制一条线。由于默认是粗为 2 像素的线条间隔 2 像素,因此这样就会出现三条线一起绘制的情况。你可以试着调整机率和间隔时间(比方说每隔 0.05s?)来取得最佳视觉效果:

dw-7


现在我们还可以做另一个细节调整:在尾迹圆完全消失前补间其半径变小的动画。这会让它更有尾迹的效果:

function Trail:new(type, x, y, opts)
  ...
  timer.tween(0.3, self, {r = 0}, 'linear', function() self.dead = true end)
end

这里我去掉了之前的 timer.after 调用改为如今的 timer.tween。 该方法参数分别为秒数,目标列表,列表中被补间的目标数值,补间的方式,以及一个可选的回调函数参数,会在补间结束后调用。在本例中,我们使用线性插值方法补间 self.r00.3 秒间的变化值,当补间动画结束后杀死该实例。效果最后像这样:

dw-8


另一个可以添加的随机效果是,我们可以随机设置每一帧尾迹圆的半径,使其富有动感:

-- in main.lua
function randomp(min, max)
    return (min > max and (love.math.random()*(min - max) + max)) or (love.math.random()*(max - min) + min)
end
-- in Trail.lua
function Trail:draw()
    love.graphics.circle('fill', self.x, self.y, self.r + randomp(-2.5, 2.5))
end

这里我们只需要定义函数 randomp, 该函数会返回一个介于 minmax 之间的随机浮点数。在本例中,我们用它来使每一帧尾迹圆的半径在默认值上下 2.5 范围内波动。

效果如下:
dw-9


另外,我们还可以基于尾迹的速度矢量大小来旋转尾迹的角度以增强表现效果。首先我们需要算出游戏实体的速度。一个简单的实现方法是存储它上一帧所在的位置,与当前位置做差。我们计算旋转角度的代码如下所示:

function GameObject:new(type, x, y, opts)
  ...
  self.previous_x, self.previous_y = x, y
end

function GameObject:update(dt)
  ...
  self.angle = math.atan2(self.y - self.previous_y, self.x - self.previous_x)
  self.previous_x, self.previous_y = self.x, self.y
end

为计算当前角度我们使用当前的 self.x, self.y 和前一帧的 x, y。接着,在最后的更新函数中,我们将当前帧的坐标值存储到 self.previous.x, self.previous_y 中。如果想要测试可以调用 print(math.deg(self.angle)) 向命令行窗口输出数值。特别需要注意的是,LÖVE 使用的是一套反转的角度系统,范围从0到负。

得到角度数值后我们可以像这样旋转正在绘制的线条((push, pop)):

-- in main.lua
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

function love.draw()
  ...
  pushRotate(160, 120, game_object.angle + math.pi/2)
  love.graphics.setBlendMode('subtract')
  for i = 0, 360, 2 do
    love.graphics.line(i, 0, i, 240)
    if trail_lines_extra_draw[i] then
      love.graphics.line(i+1, 0, i+1, 240)
    end
  end
  love.graphics.setBlendMode('alpha')
  ...
end

pushRotate函数的效果为,其余一切都会在它相对 x, y 坐标系原点旋转 r 后再绘制。这一功能在许多情况下都很实用,而本例中,我们用它来旋转游戏对象的所有线条。 我在刚才的角度数值上增加来 math.pi/2 以免其因为某种缘故偏至一边...嘛,总之,效果应该会变成下面这样:

dw-10

难以评价效果究竟是变好还是变坏了:慢速运动时看起来还不错,但速度一旦过快效果就会变得略显混乱(这个可能是因为对象停止时速度改变过大导致旋转角度剧变)。

这种特效还会导致另一个问题:条纹线条都会被旋转,由于它们一开始是匹配屏幕来绘制的,因此有可能会移动到本不应该出现的位置,让尾迹变成纯白色,无法得到我们希望的效果。为避免这种情况出现,我们可以在越过边界的所有方向上都绘制线条,这样一来无论旋转至哪个角度线条都能被正常显示:

dw-11

  ...
  trail_lines_extra_draw = {}
  timer.every(0.1, function()
    for i = -360, 720, 2 do
      if love.math.random(1, 10) >= 2 then trail_lines_extra_draw[i] = false
      else trail_lines_extra_draw[i] = true end
    end
  end)
  ...
  pushRotate(160, 120, game_object.angle + math.pi/2)
  love.graphics.setBlendMode('subtract')
  for i = -360, 720, 2 do
    love.graphics.line(i, -240, i, 480)
    if trail_lines_extra_draw[i] then
      love.graphics.line(i+1, -240, i+1, 480)
    end
  end
  love.graphics.setBlendMode('alpha')
  ...

我们要实现的最后一个酷炫的特效是:依据游戏对象(这里指我们的球形主角)的速度来改变它的形状。具体的计算可以参考上一个例子中的思路,但我们只需要算出游戏对象速度的量级就可以了,不需要算出角度:

function GameObject:update(dt)
  ...
  self.vmag = Vector(self.x - self.previous_x, self.y - self.previous_y):len()
  ...
end

这里我使用了库文件 HUMPVector 模块,并在 main.lua 中将其初始化。我略去了这一步骤不提,因为参考前文你自己应该能顺利完成它的配置。那么我们来计算一个名为 vmag 的数值,这个数值用来标识某个对象在任意方向上速度的绝对大小。由于我们已经得到了角度的数值,我们只需要算出速度的绝对大小即可。因此我们的代码如下:

-- in main.lua
function map(old_value, old_min, old_max, new_min, new_max)
    local new_min = new_min or 0
    local new_max = new_max or 1
    local new_value = 0
    local old_range = old_max - old_min
    if old_range == 0 then new_value = new_min
    else
        local new_range = new_max - new_min
        new_value = (((old_value - old_min)*new_range)/old_range) + new_min
    end
    return new_value
end
-- in GameObject.lua
function GameObject:update(dt)
  ...
  self.vmag = Vector(self.x - self.previous_x, self.y - self.previous_y):len()
  -- print(self.vmag)
  self.xm = map(self.vmag, 0, 20, 1, 2)
  self.ym = map(self.vmag, 0, 20, 1, 0.25)
  ...
end

function GameObject:draw()
  pushRotate(self.x, self.y, self.angle)
  love.graphics.ellipse('fill', self.x, self.y, self.xm*15, self.ym*15)
  love.graphics.pop()
end

我们来剥丝抽茧地分析这段代码。首先是 map 函数。这个函数有5个参数,存放旧的数值的 old_value,标识 old_value 取值范围的 old_minold_max,以及另外两个分别名为 new_minnew_max,用于标识新取值范围的参数。最后它会返回一个新值,与 old_value 对应,但会被映射到新的取值范围中。比方说,如果我们调用 map(0.5, 0, 1, 0, 100) 会返回 50;调用 map(0.5, 0, 1, 200, -200) 会返回 0;调用 map(0.5, 0, 1, -200, 100) 则会返回 -50。诸如此类……

我们利用这个函数来计算变量 self.xmself.ym。这两个变量会用来作为绘制变形椭圆的乘数因子。需要牢记一点,在绘制椭圆时,我们会先先将一切都旋转 self.angle,这就意味着,我们需要时刻按角度为 0 的情况来考虑问题,因为其他角度的情况都是由它自动变成的。

具体来说,这意味着,我们需要考虑到椭圆从水平透视图开始的形状变化。因此,当游戏实体运动得极为快速时,我们希望水平方向上拉伸它,竖直方向上收缩它,这一点反映在我们计算得出的变量 self.xmself.ym 上。正如你所见,当 self.vmag0 时,self.xm 的数值为 1, 当 self.vmag 数值增至 20self.xm 变为 2。这也就是说,当 self.vmag20 时椭圆在水平方向上的大小会加倍。随着 self.vmag 数值的增加,变化还会更大。类似地, self.ym 会变小使得椭圆在 y 方向上收缩。

特别值得一提的是,用在 map 函数中的变形参数是通过一次又一次的试错与观察得出的。它们能够营造出夸张的效果,从 gif 图中你能够感受到其强烈的表现能力。我平时可能会采用 060 而非 20 这样的数值。

让我们将这些效果应用在尾迹对象上:

-- in GameObject.lua
function GameObject:new(type, x, y, opts)
    ...
    timer.every(0.01, function() createGameObject('Trail', self.x, self.y, {r = 20, 
        xm = self.xm, ym = self.ym, angle = self.angle})
    end)
end
-- in Trail.lua
function Trail:draw()
    pushRotate(self.x, self.y, self.angle)
    love.graphics.ellipse('fill', self.x, self.y, self.xm*(self.r + randomp(-2.5, 2.5)), 
        self.ym*(self.r + randomp(-2.5, 2.5)))
    love.graphics.pop()
end

最终效果如下:
dw-12

尾声

教程到这里就告一段落了。我衷心希望各位读者掌握了文中的内容。

dw-13

快关注我的推特汤不热吧!

附录

本文译自 adonaac 的博客,略有删改,原文见这里
Zack Bell (downwell 的原作者)也写过一篇同样内容的教程,但他是基于老版本的 Game Maker 来实现的,如果有兴趣你可以前往这里查看:传送门

近期点赞的会员

 分享这篇文章

您可能还会对这些文章感兴趣

参与此文章的讨论

  1. Humble Ray 2016-05-03

    尾迹增强了动感

  2. 浑身难受 2016-05-03

    文科生看完后只能说66666= =

  3. PlayStation 2016-05-03

    还是这一篇详细一些

  4. Jerry Zhao 2016-05-03

    挺棒的教程,虽然看起来并不像入门的-O-

  5. Jerry Zhao 2016-05-03

    “Could I translate your blogs in github to Chinese for the developers from China? I will link them to your origin pages.“,看来是Indienova的同学,干得漂亮:)

  6. Astelei 2016-07-28

    cool~~~~

  7. 罗杰儿 2016-08-25

    嗯,挺不错。这种想效果也可以通过粒子系统来实现 : )

  8. ZackZ 2017-03-07

    跟原来效果有出入。仔细看了下,原来的效果貌似是每隔一行左右扩展一下sprite的像素,然后另外的行左右腐蚀一两个像素。会有一点立体感。这个效果更平面化一些。(也有可能是拷贝了下原sprite贴到尾迹上)

    最近由 ZackZ 修改于:2017-03-07 09:40:21

您需要登录或者注册后才能发表评论

登录/注册