阅读 5907

使用canvas绘制唱跳rap🏀

背景介绍

最近唱跳rap挺流行的,我经常在各种地方看到相关的信息,有个朋友的头像和签名都换成这个了,我想逗他一下,所以想着用canvas画一个。

最终效果如下:

如果好奇可以先把代码下下来跑跑看哈,代码链接在最后。

前期准备

绘制序列帧

其中那个跳舞的人只能用序列帧来做,这几天我买了一个数位板,正好用了一下,以为有了数位板就能画的很好是想多了,最终结合ps和数位板勉强画完了整个跳舞的过程。放在了一张图上:

有没有种武功秘籍的感觉~

createjs简介

自己实现帧动画还是挺复杂的,所以我使用了createjs

先简单介绍一下我对createjs所了解的一些知识,createjs包括几个部分

  • easeljs用来绘制各种图形的,包括了Stage、Container、Sprite还有Graphics等,帧动画也是这部分实现的
  • tweenjs是用来制作动画的,各种属性动画在这里实现
  • preloadjs是用来预加载资源的,绘制时难免会用到各种assets,可以用preloadjs提前加载。
  • soundjs是音频相关的一些api

easeljs组织各种图形是使用stage、container、spite三层来管理的,stage只有一个,container和sprite可以多个。我感觉比较方便的一点是之前多个canvas中绘制的内容,现在使用多个container就可以了。

我们主要用到了easeljs来组织各种图形和绘制帧动画,包括文字、跳舞的人、星星、心,使用了tweenjs来绘制属性动画,主要是星星的下落和文字的出现动画,使用了preloadjs来预加载资源,也就是跳舞的人需要的那个序列帧图片。

使用了vue、webpack,使用vue-cli生成的基本结构,但基本没用到vue的啥特性,感兴趣的自己去了解下。

绘制

初始化

首先放一个大的canvas在页面中:

<canvas ref="can" width=1200 height=600></canvas>
复制代码

然后创建根stage,和一个container

const can = this.$refs.can;

const stage = new createjs.Stage(can);     
const container = new createjs.Container();
stage.addChild(container);
复制代码

加载资源

接下来我们使用preloadjs来加载资源,只要给出一个清单就可以了,id方便后面取资源,src就是资源的路径。

const queue = new createjs.LoadQueue();
queue.loadManifest([
    { id: 'singjumprap', src: './images/singjumprap.png' },
]);
queue.on('complete', () => {
   const img =  queue.getResult('singjumprap')

});
复制代码

在加载完成之后开始我们的绘制,通过getResult拿到图片,这里的图片已经是HTMLImageElement了,可以直接用。

帧率设置

canvas绘制一般都是使用requestAnimationFrame来做定时的重绘,但帧率是不可控的,这里我们使用了createjs的Ticker来做帧率控制:

createjs.Ticker.addEventListener("tick", tick);
createjs.Ticker.setFPS(30);
复制代码

tick是每次重绘都会执行的回调函数,有一个event的参数可以拿到每次的间隔事件等信息。

function tick() {
    stage.update();
}
复制代码

需要在tick里调用stage.uopdate来更新。

绘制跳舞的人

拿到资源了,帧率设置好了,接下来就是绘制跳舞的人的帧动画了。我封装了一个方法:

function startDrawPerson(container, queue) {
    const frames = [
        [13, 250, 840, 1018],
        [1104, 250, 740, 1018],
        [2105, 250, 740,1018],
        [2994, 250, 740,1018],
        [4147, 250, 740,1018],
        [5263, 250, 740,1018],
        [6174, 250, 680,1018],
        [7100, 250, 580,1018],
        [7899, 250, 580,1018],
        [8457, 250, 720,1018],
        [9130, 250, 740,1018],
        [9899, 250, 840,1018],
        [10959, 250, 860,1018],
        [12255, 250, 740,1018],
        [13650, 250, 840,1018],
        [14457, 250, 740,1018],
        [15245, 250, 740,1018],
        [16081, 250, 740,1018],
        [16918, 250, 740,1018],
        [17716, 250, 740,1018],
        [18476, 250, 740,1018] 
    ];
    const spriteSheet = new createjs.SpriteSheet({
    	images: [queue.getResult('singjumprap')],
    	frames,
    	animations: {
    		person: [0],
    		singjumprap: frames.map((item, index) => index)
    	},
    });
    const person = new createjs.Sprite(spriteSheet, 'person');
    person.set({x:300,y:100,scaleX:0.5,scaleY:0.5}); 
    container.addChild(person);
    person.gotoAndPlay('singjumprap');
    return person;
}
复制代码

这个方法需要传入container和用来拿资源的queue,返回绘制完的sprite。

frames是序列帧的信息,每一个都包括x、y、width、height来从图中截取一部分,这就像web中的雪碧图一样。

images是我们加载好的图片,frames是从中截取出的序列帧,animations就是帧动画信息了,我们从上面的frames中取出一些帧来组成一个帧动画。

我这里取了两个帧动画,一个是开始的person,另一个是跳舞的动作。

然后初始化sprite,调整下大小和位置,之后添加到container中,执行跳舞动画。

跳舞过程的控制

现在已经可以实现跳舞了,但是有两个问题,一个是每一帧切换过快,因为我们设置了FPS是30,并且每一帧都调用了stage.update;二是跳舞是循环的,我们只希望跳一次。

这些可以在tick里控制:

let delTime = 0;
let frameIndex = 0;
let isStop = false;
function tick(evt) {
    delTime += evt.delta
    if( delTime < 300) {
        person.paused = true;
    } else {
        if (frameIndex < frames.length -1) {
            person.paused = false;
            delTime = 0;
            frameIndex++;                     
        } else {
            if(!isStop){
                startDrawHeart(container, heartCan);
                isStop = true;
            }
        }
    }
    stage.update();
}
复制代码

person就是跳舞的人的sprite对象,可以通过设置paused为true来暂停序列帧的播放,我们几率一个delTime,当超过300ms时让他动一次。

同时每次动的时候记录index,当跳到最后一个动作的时候就设置paused为true,并且不再修改。

然后开始绘制❤️。

绘制跳动的心

首先我们先把心绘制出来,我使用了另一个canvas:

<canvas ref="heartCan" width=50 height=60 style="display:none;"></canvas>
复制代码

拿到context,然后开始绘制

const heartCan = this.$refs.heartCan;
const heartCtx = heartCan.getContext('2d');
drawHeart(heartCtx, 25,25,10, 0);
复制代码

绘制心的函数,需要传入context,以及x、y、r以及旋转角度rot

function drawHeart(ctx,x,y,R,rot) { 
    function heartPath(ctx) { 
    	ctx.beginPath(); 
    	ctx.arc(-1,0,1,Math.PI,0,false); 
    	ctx.arc(1,0,1,Math.PI,0,false); //貝塞尔曲线画心 
    	ctx.bezierCurveTo(1.9, 1.2, 0.6, 1.6, 0, 3.0); 
    	ctx.bezierCurveTo( -0.6, 1.6,-1.9, 1.2,-2,0); 
    	ctx.closePath(); 
    }
    ctx.save(); 
    ctx.translate(x,y); 
    ctx.rotate(rot/180*Math.PI); 
    ctx.scale(R, R); 
    heartPath(ctx);
	ctx.fillStyle = "red"; 
	ctx.shadowColor = "gray"; 
	ctx.shadowOffsetX = 2; 
	ctx.shadowOffsetY = 2; 
	ctx.shadowBlur = 2; 
	ctx.fill(); 
} 
复制代码

心的是使用贝塞尔曲线来绘制的心形路径,之后设置了fillStyle和shadow来填充。

接下来把绘制的心通过Bitmap类型的Sprite创建并添加到container中,并且开始执行跳动的动画。

const startDrawHeart = (container, heartImg) => {
    var bitmap = new createjs.Bitmap(heartImg);
    bitmap.x = 385;
    bitmap.y = 280;
    bitmap.width = 50;
    bitmap.height = 50;
    container.addChild(bitmap);
    const heartBounce = () => {
        setTimeout(heartBounce, 1000);
        createjs.Tween.get(bitmap).to({scale: 1.3}, 500).to({scale: 1}, 500);
    }
    heartBounce();
}
复制代码

跳动动画逻辑比较简单,就是scale从1到1.3到1之间变换,每次时间间隔都是500ms。然后1秒执行一次动画。

绘制文字和星星坠落

画完之后我觉得表意不够明确,所以我加了一行文字,之后又在文字上加了一些星星。

星星

先绘制星星:

<canvas ref="canStar" width=20 height=20 style="display:none;"></canvas>
复制代码
const starCan = this.$refs.canStar;
const starCtx = starCan.getContext('2d');
drawStar(starCtx, 1);
复制代码
const drawStar = (ctx, scale, color = 'gold') => {
    ctx.save();
    ctx.clearRect(0, 0, 100, 100);
    ctx.strokeStyle = color;
    ctx.scale(scale, scale);
    ctx.beginPath();
    ctx.moveTo(0, 10);
    ctx.lineTo(20, 10);
    ctx.moveTo(10, 0);
    ctx.lineTo(10, 20);
    ctx.moveTo(5, 5);
    ctx.lineTo(15, 15);   
    ctx.moveTo(5, 15);
    ctx.lineTo(15, 5);
    ctx.closePath();
    ctx.stroke();
    ctx.restore();
}
复制代码

和绘制心是一样的过程,我画的星星很简单,就是横竖斜线~

文字
const texts = "从未有人像你让我如此怦然心动";
const textAnimInfos = texts.split('').map((item, index) => {
	let text = new createjs.Text(item, "40px monospace", "#000000");
	text.x = 150 + index * 50;
	text.y = 50;
	text.rotation = random( -30, 30);
	text.scale = random(1.2, 1.5);
	return text;
});
let i = 0;
function renderText() {
	const text = textAnimInfos[i];
	container.addChild(text);
	createjs.Tween.get(text).to({rotation: random(-10, 10), scale: random(0.8, 1)}, 100);

	i++;    
	if (i < texts.length) {
		setTimeout(renderText, 350); 
	}          
}
setTimeout(() => {
	renderText();
}, 2000);

复制代码

因为每个文字都有出现动画,所以每个是一个单独的sprite,先循环生成每个文字的x、y以及初始的rotation和scale等信息,放到textAnimInfos中,每两秒往container中添加一个,出现时执行一些scale和rotation的动画。

然后绘制星星和星星坠落动画:

let j =0;
function renderStar() {
    const text = textAnimInfos[j];
    for (let i =0; i< 10; i++){
    	const startX =text.x + random(0, 20);
    	const startY =text.y + random(0, 20);
    	var star = new createjs.Bitmap(starCan);
    	star.x = startX;
    	star.y = startY;
    	star.width = 10;
    	star.height = 10;
    	container.addChild(star);
    	createjs.Tween.get(star).wait(0).to({y: random(1000, 1200), x: random(0, 1200)}, random(5000, 8000));
    }
    
    j++;
    if (j < texts.length) {  
    	setTimeout(renderStar, 350); 
    }
}

setTimeout(() => {
	renderText();
	renderStar();
}, 2000);
复制代码

星星因为是和文字结合的,这里位置信息和文字关联,每次绘制从textAnimInfos中取出文字的x、y,然后创建10个(这里可以调整)随机位置的星星添加到容器中,并且执行一个坠落动画。

这里的坠落动画没用啥运动公式,只是随机了一个下方的结束位置并且随机了运动时间。但感觉坠落的还挺好看的~

源码链接

代码在这,需要自取,有问题可以提issue哈 canvas-singjumprap