第 6章 动画与音效

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

[TOC]

动画与音效

动画

动画能够很好的提高画面的可视性,当然,动画不单单是一个单纯的贴图替换的过程,也可能包含图片颜色的改变,位置的改变,形状的改变等等。就平时常用的而言,动画分为下面几种:帧动画,骨骼动画,粒子效果,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来控制播放即可。下面通过代码演示:

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。
所以有下面代码:

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。

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上找到。

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来导入的,因为很简单下面仅举个例子。

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

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

编程时间

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

设计阶段

飞机能够跟随鼠标的方向转动,并自动向前飞行,飞机有转动速度。
飞机能够按下按键时发射子弹。
飞机绑定一个动画。
设计一个子弹类,子弹在射出时固定角度,按固定速度移动,离开图像边界子弹销毁。
设计一个敌人飞机类,并绑定一个动画,敌人飞机自动发射子弹,但敌人飞机的子弹速度要慢一点。
敌人的初始位置为以画面为中心,半径rx = 400,ry =300的随机外围位置。并以画面中心为目标飞行。
敌人飞机在超过边界时,销毁并重新生成一个飞机。
暂时不涉及碰撞。留作作业。
根据上述设计要求,我们首先确定了制作3个类,一个玩家,一个敌人,一个子弹。动画部分我们准备使用anim8库。
玩家跟随鼠标的算法,我们需要考虑下如何实现,敌人生成的位置还是比较简单的。

实现阶段

  1. 三个类的基本框架,我们在上一章已经讲过了,这里不再赘述。(实际上,敌人飞机虽然像飞机,但是它跟子弹在代码上更加相似。所以,有时候对于游戏来讲,要从一个物体的行为本质思考,而不是外面的皮肤是什么样的)
  2. 飞机类跟随鼠标的算法,这里简单讲解一下。先把代码贴出来,再简要的说明:
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. 关于敌人飞机发射子弹以及子弹减速的问题。
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. 敌人飞机生成位置
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掉就可以了。
    第一种方法,我叫做另起炉灶

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

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

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

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

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

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

作业

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

本章代码

--------------------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
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,142评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,298评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,068评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,081评论 1 291
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,099评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,071评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,990评论 3 417
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,832评论 0 273
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,274评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,488评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,649评论 1 347
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,378评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,979评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,625评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,796评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,643评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,545评论 2 352

推荐阅读更多精彩内容

  • 模型(Mesh) 在Unity3D中使用三维模型,主要依靠Mesh Filter组件载入多边形表面物体(polyg...
    shimmery阅读 21,370评论 2 34
  • 一:什么是协同程序?答:在主线程运行时同时开启另一段逻辑处理,来协助当前程序的执行。换句话说,开启协程就是开启一个...
    CrixalisAs阅读 2,070评论 1 7
  • 1、委托是什么,事件是委托吗? 它们有什么区别? C#中委托通常是指委托类型创建的对象,它用于保存和调用同类型的方...
    SeriousWilson阅读 2,237评论 0 1
  • 一. 简介 动画在2D游戏里用得十分广泛, 根据这些动画的特点,我们可以大概归为3类 1. 粒子动画 这种动画是由...
    955a74228446阅读 1,884评论 0 1
  • 今天感恩节哎,感谢一直在我身边的亲朋好友。感恩相遇!感恩不离不弃。 中午开了第一次的党会,身份的转变要...
    迷月闪星情阅读 10,562评论 0 11