大数据

在canvas上运行sprite动画

有天我接到一个朋友的求助,说不知道如何在web页面上画一个会飞的蝴蝶。我回答说,这个挺简单的,CSS帧动画就可以搞定啊——让设计师给你输出每一帧蝴蝶的位置、和每一帧蝴蝶翅膀的动作,你把它们逐帧播放到div上就可以了。他说不行,他们试过输出帧动画,这个蝴蝶的动作要持续比较久而且不重复,这样就需要下载许多帧的图片,页面加载会很慢。

想要的效果 – 飞行+转向

那么有什么办法解决上面这个问题呢?我给出的答案是,把蝴蝶飞的过程拆分成三部分:

  1. 扇翅膀
  2. 转向
  3. 前进

扇翅膀

所以我的基本思路是:先用”sprite”的方式在canvas上画出蝴蝶扇翅膀的动画,然后用CSS帧动画来表现转向和前进。

“sprite”是游戏开发中比较重要的一个概念,有的地方会翻译成“精灵”,它不是某项技术的名称,而是代指在游戏开发中一个最小可交互单元。这些交互就包括动画和运动。

首先需要一个SpriteSheet作为素材

SpriteSheet在有的地方被称作“雪碧图”,因为Sprite的拼写跟“雪碧”的英文一模一样——但是这里想表达的意思就是“构成一个Sprite需要用到的图”。一个SpriteSheet大概看起来是这样的:

包含了许多“蝴蝶”的SpriteSheet

图片资源来自:https://idrilart.wordpress.com/category/resources/ (需梯子)

这张图上有8种颜色的蝴蝶,每种刚好有4行图片,分别代表了“飞”这个动作的4个方向,每行有3幅图。也就是说,我如果我要制作一个粉色蝴蝶向上飞的效果,就需要取这张图片的第5行,1-3列的图片在canvas上进行连续播放,就可以完成“粉色蝴蝶在向上飞”这个精灵动画

用sprite的概念制作一只蝴蝶

首先要做的肯定是加载出这张图片了:

var image = new Image();
image.src = 'path-to-sprite-sheet.png';

然后需要创建一个sprite类,并且实例化一个butterfly:

var sprite = function(opt) {
    var sprite_proto = {};
    sprite_proto.image = opt.image;
    sprite_proto.rect = opt.rect;
    sprite_proto.frames = opt.frames;
    return sprite_proto;
}

var butterfly = sprite({
    image: image,
    rect:{x: 0, y: 0, w: 17, h: 17},
    frames: [
        {x: 199, y: 96, w: 17, h: 17},
        {x: 231, y: 96, w: 17, h: 17},
        {x: 263, y: 96, w: 17, h: 17}
    ]
});

这里的rect表示sprite将会被绘制在canvas的什么位置,以什么样的长和宽来绘制,所以是一个包含x,y,w,h四个参数的对象,你也可以理解为字典——在JavaScript中是一个意思。而frames中的x,y,w,h则表示从上面那张“SpriteSheet”中如何定位一个“frame”。

所以接下来要做的事情就是:

根据butterfly取得frame -> 根据frame取得图片的一部分 -> 将这部分图片画在canvas上

一般情况,每一帧的x,y,w,h数据在SpriteSheet制作好的时候就已经生成了,如果是像我这样从网上找来的SpriteSheet,可以试试用一个叫“Markman”的软件来标注出每一帧的位置。

用Markman标注一帧蝴蝶

把蝴蝶画在canvas上

好,终于到canvas了,在html上放一个canvas很简单:


    
        
    

在JavaScript中初始化它,然后创建一个world类来控制canvas的绘制:

var canvas = document.getElementById('butterfly');
canvas.width = 800;
canvas.height = 600;

var world = function(opt) {
    var world_proto = {};
    world_proto.context = opt.context;
    world_proto.rect = opt.rect;
    world_proto.render = function(sprite) {
        var frame = sprite.frames[0];
        this.context.drawImage(
            sprite.image,
            frame.x,
            frame.y,
            frame.w,
            frame.h,
            0,
            0,
            sprite.rect.w,
            sprite.rect.h
        );
    }
    return world_proto;
}

var my_world = world({
    context: canvas.getContext('2d'),
    rect: {x: 0, y: 0, w: 800, h: 600}
});

最后,执行my_world.render,就可以看到蝴蝶在canvas上被画出来了吗?不对,这时候可能图片还没有加载好,因为Sprite,所以需要等图片加载好的时候再执行my_world.render

image.addEventListener('load', function(){
    my_world.render(butterfly);
});

让蝴蝶动起来

做完上面这些,已经能在canvas上看到一只蝴蝶了——不过是静止的。下面要做的事情就是每次在执行my_world.render的时候,取不同的frame进行绘制。这里首先要改写一下sprite:

var sprite = function(opt) {
    var sprite_proto = {};
    sprite_proto.image = opt.image;
    sprite_proto.rect = opt.rect;
    sprite_proto.frames = opt.frames;
    sprite_proto.current_frame_idx = 0;
    sprite_proto.next = function() {
        var cur_frame = this.frames[this.current_frame_idx];
        this.current_frame_idx += 1;
        if (this.current_frame_idx == this.frames.length) {
            this.current_frame_idx = 0;
        }
        return cur_frame;
    };
    return sprite_proto;
}

在上面的sprite定义中,我添加了一个属性current_frame_idx来表示当前所处的frame,然后添加了一个方法next来获取当前frame,并将current_frame_idx增加1。

对应的,world的render方法也需要修改:

world_proto.render = function(sprite) {
    var frame = sprite.next();
    this.context.drawImage(
        sprite.image,
        frame.x,
        frame.y,
        frame.w,
        frame.h,
        0,
        0,
        sprite.rect.w,
        sprite.rect.h
    );
}

最后,如果要让frame在canvas上不断更新,是不是循环调用my_world.render就可以了?答案是:不可以。因为JavaScript如果在执行一个循环,那么canvas的绘制过程就会等循环执行结束后才开始——也就是说,如果把my_world.render写进一个无限循环里,那我们什么也看不到,因为canvas根本没开始绘制。

正确的做法是使用JavaScript的setInterval方法,类似这样:

image.addEventListener('load', function(){
    setInterval(function(){
        my_world.render(butterfly);
    },200);
});

这段代码依然是在image加载完成执行的。

一个小问题

所有上面这些代码足够让蝴蝶能够在一个固定位置扇翅膀了,但是有个小问题,render方法对canvas的影响是叠加的,所以最后这只蝴蝶看起来是这样的:

“叠在一起”的蝴蝶

所以,要在render之前,清理掉上一帧的蝴蝶。给world加一个方法:

world_proto.clear = function() {
    this.context.clearRect(0, 0, this.rect.w, this.rect.h);
}

然后改写setInterval执行的部分:

image.addEventListener('load', function(){
    setInterval(function(){
        my_world.clear();
        my_world.render(butterfly);
    },200);
});

这样蝴蝶就能”看起来正常”地扇翅膀。

  • 本文的代码可以在这里找到
  • 关于怎么让蝴蝶按一定轨迹飞,这次暂时省略,有机会可以和贝塞尔曲线一起总结一下