第6章 动画与音效

本章内容:通过之前几章的学习,我们学到了大多数游戏所需的东西,你现在完全可以做一些小游戏了。比如FC游戏小蜜蜂(Galaxian)。或者坦克大战等等。但是,现在还不太好玩,因为画面比较生硬,而且没有声音,现在,我们就开始介绍如何做出动画效果及播放声音。

动画与音效

动画

动画能够很好的提高画面的可视性,当然,动画不单单是一个单纯的贴图替换的过程,也可能包含图片颜色的改变,位置的改变,形状的改变等等。就平时常用的而言,动画分为下面几种:帧动画,骨骼动画,粒子效果,mesh变形等等。他们并没有一个完全严格意义的概念划分,只是我们在使用时的人为划分。

帧动画

帧动画是2d游戏中最常用的动画形式,因为它简单易用,基本不涉及什么计算,画面表现丰富。当然,它也有相应的缺点,比如贴图占据空间大,越流畅、精细的画面,占据内存空间越大,绘制比较麻烦,因为每一帧几乎等于重绘。
帧动画还有一种变体,是将动画的各个部分划分为小的动画,比如头部,手臂等,然后把他们通过固定的位置拼接在一起。这种好处是可以替换某个部分来实现换装备,换武器或者一个类型的动画可以组装多种人物等等。

骨骼动画

骨骼动画是比较复杂的动画,但是它有很多优点,比如可以复用贴图,不用画很多帧,方便控制,可以结合mesh动画等。目前比较流行的有spine和dragonbone两个骨骼动画编辑器。其中spine有对应love2d的运行时,所以一般使用骨骼动画都用spine,不过spine本身是收费的,而且不便宜。骨骼动画的原理是将一个人物分为若干块骨头,我们主要控制骨头的长短,角度,位置等,然后骨头上绑定贴图,就可以了,所以最简单的骨骼动画记录的是随时间变化各个骨骼的位置和角度。
刚刚提到的帧动画变体实际上有时也可以加入骨骼动画,只不过一般不太设计关节角度的继承关系,而是直接用绝对位置和绝对角度。因为他们的编辑比较复杂,或者没有一个统一的定式,这里不多讲,后面可以针对性的自己试着实现。

粒子效果

实际上,粒子效果本身不太算动画部分,但是因为他们也涉及贴图,位置,角度,大小等的变化,所以放在这里将。粒子效果实际上是一大批的小图片,按照预先设计好的存在时间,位置,速度,角度,加速度,颜色等等(很多有随机成分),加入到场景,并当存在时间到时时自动消失。我们常用见的比如,魔法效果,火星,灰尘,闪光,火焰等,均可由粒子效果来模拟。对了,加一句计算机图形的名言“这个东西看起来是真的,那它就是真的”。

mesh动画

也可以叫做mesh的动态形变。如果知道些绘图原理或者3d渲染的同学,应该理解什么是mesh,它是计算机渲染图片的形式,计算机会把欲绘制的位置与纹理的位置做一个对应关系,一般至少要3个点来完成。一个方形的贴图一般需要4个点,即两个三角形来完成渲染。而mesh动画则是把一个贴图布满mesh网格,每个单元都是三角形。然后通过控制这些单元的顶点位置来达到变形效果。这种变形往往比较有张力,而且立体。比如一个软球,一个飘动的旗子,一些透视形变等等。这种效果一般也会结合骨骼动画来实现,比如spine。所以有时,spine能做出以假乱真的3d效果。

本章仅具体介绍一下帧动画的原理和库,骨骼动画可以到spine的运行时的github上找到案例(去找一个叫love2d的branch),粒子效果可以找一些粒子工具来体验一下感觉,其实所有的参数都是通用的。mesh动画就还是找spine运行时。

帧动画原理及实现。

基本原理

帧动画的基本远离实际上跟电影胶片差不多,就是利用人的视觉残像原理,以及人脑自动补全的原理的,通过快速的切换一些画面的显示来达到某个图片看起来像在动一样。
帧动画一般涉及几个要素,一个是帧序列,就是一组图片。一个是延迟时间,帧与帧之间的延迟间隔。还有一个是循环模式,比如单次,顺序循环,乒乓循环几种。

多图帧动画

多图是比较简单的方式,每个图片是已经切割好的纹理,我们按正常的方法建立一组image对象,然后通过一个timer来控制播放即可。下面通过代码演示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
local images = {}
local cd = 1/20
local timer = cd
local index = 1
for i = 1, 10 do
images[i] = love.graphics.newImage("res/image"..i..".png")
end
function Animation_update(dt)
timer = timer - dt
if timer<0 then
timer = cd
index = index + 1
if index>#images then
index = 1
end
end
end
function Animation_draw()
love.graphics.draw(images[index])
end

上面的代码比较简单,唯一需要注意的是引入image对象时,对文件名的处理,可以用string.format进行格式化。

spritesheet帧动画

精灵表帧动画的原理实际跟上面是没有区别的,只是由于如果图片不是二次幂(自己百度)的话,它实际占用的显存要高于图片本身。所以一般把图片整理后放到一个大图片中,这个图片叫做精灵清单,压制这种图片的工具比较多,比较著名的是spritepacker,额,也是个收费工具。在love论坛中有对其文件格式解读的工具,由于笔者并不用这个软件,所以请自行查阅。另外,在网上还有很多并没有给出具体拆分方式的精灵清单。如果他们的间隔是固定的,(比如一些人做的是64*64大小,每个精灵之间再额外间隔2个像素),这种就比较容易处理。不过有素材,可能是从某些游戏导出的,原本是存在一个清单的,但是导出时没能找到,这种情况就需要手动切图,在重新排布了。常用的有比如photoshop,graphicgale等。
从代码实现上,与上面的代码的区别在于需要引入love的另一个对象叫做quad。它可以用来作为draw的第二个参数,从而告诉draw函数要绘制目标的哪个矩形区域。
quad在定义时,需要x,y,w,h参数,另外还需要一个图片整体大小。需要说明的是,像素的起点是0,0 而非1,1 所以宽100,高100的图片,实际x的取值范围是0,99。
所以有下面代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
local image = love.graphics.newImage("res/imagesheet.png")
local imageW,imageH = image:getDimensions()
local cd = 1/20
local timer = cd
local index = 1
local frames = {}
for i = 1, 10 do
frames[i] = love.graphics.newQuad((i-1)*64-1,0,64,64,imageW,imageH)
end
function Animation_update(dt)
timer = timer - dt
if timer<0 then
timer = cd
index = index + 1
if index>#frames then
index = 1
end
end
end
function Animation_draw()
love.graphics.draw(image,frames[index])
end

帧动画库

常用的帧动画库anim8,它建立动画分为两个步骤,首先建立一个网格,然后通过网格建立动画,具体用法可以自行github。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
local anim8 = require 'anim8'
local image, animation
function love.load()
image = love.graphics.newImage('path/to/image.png')
local g = anim8.newGrid(32, 32, image:getWidth(), image:getHeight())
animation = anim8.newAnimation(g('1-8',1), 0.1)
end
function love.update(dt)
animation:update(dt)
end
function love.draw()
animation:draw(image, 100, 200)
end

另外,笔者也自己写了一个简单的animation库,可以在笔者github上找到。

1
2
3
4
5
6
7
8
local Animation = require "animation"
local anim = Animation:new(img,fx,fy,w,h,offx,offy,lx,ly,delay,count)
function love.update(dt)
anim:update(dt)
end
function love.draw()
anim:draw()
end

其中参数img为图片对象,fx,fy为含有动画的第一帧左上角位置,w,h为每个帧的尺寸,offx,offy为帧之间的间隔,lx,ly为最后一帧右下角的位置,delay为帧延迟时间,count为取这个动画中的前多少帧(可以缺省为最多)。这个库包含一些基本的回调。因为很简单,所以自己看源码就看得懂。

声音

在love2d里,声音对象是由love.audio.newSource来导入的,因为很简单下面仅举个例子。

1
2
3
local music = love.audio.newSource("love.mp3")
music:play()
-- love.audio.play(music)

有两点要说明,newSource的参数的第二个为类型,有两种,一种是静态一种是流式,前者比较快,但占内存,后者相反。缺省为流式。如果像类似机枪那种连续的播放某个声音的话,需要建立多个声音对象,如果仅一个的话,只能在这个声音完全播完,才能再次播放。

编程时间

这次我们的坦克再次升级啦,改为飞机了,现在给坦克披上飞机的皮,它就是飞机了,不过炮塔也没有了,用鼠标来操控飞机的方向。

设计阶段

  1. 飞机能够跟随鼠标的方向转动,并自动向前飞行,飞机有转动速度。
  2. 飞机能够按下按键时发射子弹。
  3. 飞机绑定一个动画。
  4. 设计一个子弹类,子弹在射出时固定角度,按固定速度移动,离开图像边界子弹销毁。
  5. 设计一个敌人飞机类,并绑定一个动画,敌人飞机自动发射子弹,但敌人飞机的子弹速度要慢一点。
  6. 敌人的初始位置为以画面为中心,半径rx = 400,ry =300的随机外围位置。并以画面中心为目标飞行。
  7. 敌人飞机在超过边界时,销毁并重新生成一个飞机。
  8. 暂时不涉及碰撞。留作作业。

根据上述设计要求,我们首先确定了制作3个类,一个玩家,一个敌人,一个子弹。动画部分我们准备使用anim8库。
玩家跟随鼠标的算法,我们需要考虑下如何实现,敌人生成的位置还是比较简单的。

实现阶段

  1. 三个类的基本框架,我们在上一章已经讲过了,这里不再赘述。(实际上,敌人飞机虽然像飞机,但是它跟子弹在代码上更加相似。所以,有时候对于游戏来讲,要从一个物体的行为本质思考,而不是外面的皮肤是什么样的)
  2. 飞机类跟随鼠标的算法,这里简单讲解一下。先把代码贴出来,再简要的说明:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    local function getRot(x1,y1,x2,y2)
    if x1==x2 and y1==y2 then return 0 end
    local angle=math.atan((x2-x1)/(y2-y1))
    if y1-y2<0 then angle=angle-math.pi end
    if angle>0 then angle=angle-2*math.pi end
    return -angle
    end
    local function unitAngle(angle) --convert angle to 0,2*Pi
    angle = angle%(2*math.pi)
    if angle > math.pi then angle = angle - 2* math.pi end
    return angle
    end
    local tx,ty = love.mouse.getPosition()
    local rot = unitAngle(getRot(self.x,self.y,tx,ty)) --这里是计算方位角的一种方法。
    self.rot = unitAngle(self.rot)
    if rot>self.rot and math.abs(rot - self.rot)< math.pi or
    rot< self.rot and math.abs(rot - self.rot)> math.pi then
    self.rot = self.rot + self.dr
    else
    self.rot = self.rot - self.dr
    end

首先,获取方位角的公式,上一章已经讲过了,数学方法不说了,当黑箱使用。返回的是两点间方位角。
第二个函数是获得单位角度,因为我们的角度不断叠加,可能不在-Pi~Pi(因为这个区间段比较容易跟0进行比较)的范围内,但是由于三角函数都是周期函数,所以从外观上没有什么影响,但是如果进行加减或比较,就要出问题了,所以要进行归一化。同样,数学方法不再解释了。
然后我们到判断转向部分,self.dr是飞机的转动速度,首先我们得到鼠标与飞机的方位角rot,然后跟飞机当前的角度进行比较,如果方位角大于飞机的角又小于半圈(math.pi)则右转,或者小于飞机角度,但大于半圈(这种情况实际是飞机处于正方向的两端),否则左转。(图示我下次修订教程的时候再补).

  1. 关于敌人飞机发射子弹以及子弹减速的问题。
    1
    2
    3
    4
    local b = Bullet(self)
    b.vx = b.vx/3
    b.vy = b.vy/3
    table.insert(game.objects,b)

在敌人发射子弹时,生成一个子弹实例,实际上跟玩家是一样的。要减速,则要改变子弹实例的速度(而非类的速度,否则所有子弹均被改变,不能改变模板),这里注意的是,直接设置speed属性是有问题的,因为后面子弹update里根本没有涉及speed而是vx,vy,所以要改变它们,让他们各自打3折即可。

  1. 敌人飞机生成位置
    1
    2
    3
    4
    local rot = love.math.random()*2*math.pi
    self.x = math.sin(rot)*400 + 400
    self.y = -math.cos(rot)*300 + 300
    self.rot = rot+math.pi

如何随机的在一个圈的位置上生成位置?同样是数学问题,不想多说了,代码在上面,因为rot是指向外圈的,所以用rot+pi就是反向指向内圈。(注意不是-rot,-rot的意义是沿0对称,而非反向,周期性不解释)

  1. 关于anim8的用法
    请自己参阅anim8的文档,这里不再赘述。

  2. 动态生成/删除飞机
    我们之前说过了,在某个对象遍历过程中,增添或删除对象是十分危险的,因为有序表很可能排序被打乱。一般而言,有两种解决方案,我们任选。当然,如果你是无序表就无所谓了,但是也不能用insert或remove来控制,直接nil掉就可以了。
    第一种方法,我叫做另起炉灶

    1
    2
    3
    4
    5
    6
    new = {}
    for i ,v in ipairs(objects)
    v:update() --table.insert(new,newObj)插入到new中,而非当前
    if not v.destroyed then table.insert(new,v) end
    end
    objects = new

第二种方法,是一种变通的方法。

1
2
3
4
5
for i = #objects,1 ,-1 do
local go = objects[i]
go:update(dt) -- table.insert(objects,#objects,newObj) 倒序遍历 加入时放到队尾
if go.destroyed then table.remove(objects,i) end
end

当然,还有一种方法就是不在遍历中加入,而是放到后面。

1
2
3
if #objects<5 then --不过这种方法比较有局限性
table.insert(objects,newObj)
end

上面介绍了本章节中比较复杂的代码,其他的东西实际上是一样的。只是代码的量增加了。

作业

  1. 增加些碰撞测试吧。让敌人及其子弹对玩家有碰撞,一旦碰撞就gameover了。(使用bump库很简单啦)
  2. 增添一些敌人的种类,子弹的方向等。自定义速度,子弹方向,及子弹速度,颜色等。(需要稍微改一下bullet类,以便支持更多的自定义)
  3. 把这个游戏改编一下,变成常见的纵版弹幕的样式,即飞机随机从屏幕上方飞往下方,发射子弹。玩家鼠标控制飞机移动,方向永远对着屏幕上方。然后设计几种不同的武器类型,比如横向的,散弹的,追踪的(算法跟用鼠标控制飞机方向类似)等等。

本章代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
--------------------import -------------------
class = require "assets/middleclass"
anim8 = require "assets/anim8"
---------------------objects-------------------
Bullet = class("bullet")
Bullet.fireCD = 0.1
Bullet.radius = 5
Bullet.speed = 20
function Bullet:init(parent,rot)
self.parent = parent
self.rot = self.parent.rot
self.x = self.parent.x + math.sin(self.rot)*self.parent.w
self.y = self.parent.y - math.cos(self.rot)*self.parent.w
self.vx = self.speed * math.sin(self.rot)
self.vy = -self.speed * math.cos(self.rot)
self.tag = "bullet"
end
function Bullet:update(dt)
self.x = self.x + self.vx
self.y = self.y + self.vy
if self.x > 800 or self.x<0 or self.y<0 or self.y > 600 then --边界判断
self.destroyed = true
end
end
function Bullet:draw()
love.graphics.setColor(255,255,0,255)
love.graphics.circle("fill",self.x,self.y,self.radius)
end
local Plane = class("plane")
Plane.speed =3
Plane.size = 1
Plane.texture = love.graphics.newImage("assets/res/1945.png")
Plane.g64 = anim8.newGrid(64,64, 1024,768, 299,101, 2)
Plane.dr = 0.1
function Plane:init(x,y,rot)
self.x = x
self.y = y
self.rot = rot
self.fireCD = Bullet.fireCD
self.fireTimer = self.fireCD
self.anim = anim8.newAnimation(self.g64(1,'1-3'), 0.1)
self.w = self.size * 32
end
local function getRot(x1,y1,x2,y2)
if x1==x2 and y1==y2 then return 0 end
local angle=math.atan((x2-x1)/(y2-y1))
if y1-y2<0 then angle=angle-math.pi end
if angle>0 then angle=angle-2*math.pi end
return -angle
end
local function unitAngle(angle) --convert angle to 0,2*Pi
angle = angle%(2*math.pi)
if angle > math.pi then angle = angle - 2* math.pi end
return angle
end
local function getLoopDist(p1,p2,loop)
loop=loop or 2*math.pi
local dist=math.abs(p1-p2)
local dist2=loop-math.abs(p1-p2)
if dist>dist2 then dist=dist2 end
return dist
end
function Plane:update(dt)
self.anim:update(dt)
local tx,ty = love.mouse.getPosition()
local rot = unitAngle(getRot(self.x,self.y,tx,ty)) --这里是计算方位角的一种方法。
self.rot = unitAngle(self.rot)
if rot>self.rot and math.abs(rot - self.rot)< math.pi or
rot< self.rot and math.abs(rot - self.rot)> math.pi then
self.rot = self.rot + self.dr
else
self.rot = self.rot - self.dr
end
self.fireTimer = self.fireTimer - dt --这里的开火计时器是十分常用的一种方法,需要学会
if love.mouse.isDown(1) and self.fireTimer < 0 then
self.fireTimer = self.fireCD
table.insert(game.objects,Bullet(self))
end
self.x = self.x + self.speed*math.sin(self.rot)
self.y = self.y - self.speed*math.cos(self.rot)
end
function Plane:draw()
love.graphics.setColor(255, 255, 255, 255)
self.anim:draw(self.texture,self.x,self.y,self.rot,self.size,self.size,32,32)
end
local Enemy = class("Enemy")
Enemy.speed =3
Enemy.size = 1
Enemy.texture = Plane.texture
Enemy.g64 = Plane.g64
function Enemy:init()
local rot = love.math.random()*2*math.pi
self.x = math.sin(rot)*400 + 400
self.y = math.cos(rot)*300 + 300
self.rot = -rot
self.fireCD = 0.5
self.fireTimer = self.fireCD
self.anim = anim8.newAnimation(self.g64('2-4',3), 0.1)
self.w = self.size * 32
self.tag = "enemy"
end
function Enemy:update(dt)
self.x = self.x + self.speed*math.sin(self.rot)
self.y = self.y - self.speed*math.cos(self.rot)
self.fireTimer = self.fireTimer - dt --这里的开火计时器是十分常用的一种方法,需要学会
if self.fireTimer < 0 then
self.fireTimer = self.fireCD
local b = Bullet(self)
b.vx = b.vx/3
b.vy = b.vy/3
table.insert(game.objects,b)
end
if self.x > 800 or self.x<0 or self.y<0 or self.y > 600 then --边界判断
self.destroyed = true
end
end
function Enemy:draw()
love.graphics.setColor(255, 255, 255, 255)
self.anim:draw(self.texture,self.x,self.y,self.rot,self.size,self.size,32,32)
end
game = {}
function love.load()
love.graphics.setBackgroundColor(100, 100, 200, 255)
game.objects = {}
game.enemies = {}
game.plane = Plane(400,300,0)
for i = 1, 5 do
table.insert(game.enemies,Enemy())
end
end
function love.update(dt)
game.plane:update(dt)
for i = #game.objects,1 ,-1 do
local go = game.objects[i]
go:update(dt)
if go.destroyed then table.remove(game.objects,i) end
end
for i = #game.enemies,1 ,-1 do
local go = game.enemies[i]
go:update(dt)
if go.destroyed then table.remove(game.enemies,i) end
end
if #game.enemies<5 then
table.insert(game.enemies,Enemy())
end
end
function love.draw()
game.plane:draw()
for i,v in ipairs(game.objects) do
v:draw()
end
for i,v in ipairs(game.enemies) do
v:draw()
end
end