美文网首页
第 6章 动画与音效

第 6章 动画与音效

作者: 我和我的火柴 | 来源:发表于2021-04-10 12:32 被阅读0次

    本章内容:通过之前几章的学习,我们学到了大多数游戏所需的东西,你现在完全可以做一些小游戏了。比如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
    

    相关文章

      网友评论

          本文标题:第 6章 动画与音效

          本文链接:https://www.haomeiwen.com/subject/qhxnkltx.html