HTML5 Canvas开发详解(5) -- 动画

2022-04-07 15:59:52 浏览数 (1)

5.1 事件操作

5.1.1 鼠标事件

在Canvas中,鼠标事件分为以下三种:

1)鼠标按下:mousedown

2)鼠标松开:mouseup

3)鼠标移动:mousemove

在Canvas中,mousedown、mouseup和mousemove这三种事件常用于实现拖拽功能。

监听鼠标位置:

每个鼠标事件都有两个用于确定鼠标当前位置的属性:pageX和pageY(存在兼容性,可以使用clientX和clientY属性替代)。

下面创建一个tools对象,把所有方法都放在这个对象中:

代码语言:javascript复制
'use strict';

const tools = {};

//监听鼠标位置
tools.listenMousePosition = function(el, func){
    let mouse = {x: 0, y: 0};
    el.addEventListener('mousemove', (event) => {
        let x,y;
        let e = event || window.event;
        if(e.pageX || e.pageY){
            x = e.pageX;
            y = e.pageY;
        }else {
            x = e.clientX   document.body.scrollLeft || document.documentElement.scrollLeft;
            y = e.clientY   document.body.scrollTop || document.documentElement.scrollTop;
        }
        x -= el.offsetLeft;
        y -= el.offsetTop;
        mouse.x = x;
        mouse.y = y;
        func(mouse);
    });
};

export default tools;

5.1.2 键盘事件

在Canvas中,常用的键盘事件有两种:

1)键盘按下:keydown

2)键盘松开:keyup

Canvas元素本身不支持键盘事件,一般情况下使用window对象来实现Canvas中键盘事件的监听。

监听键盘按键:

代码语言:javascript复制
//监听键盘按键
tools.listenKeyboardKey = function(func){
    window.addEventListener('keydown', (e) => {
        let key = '';
        switch(e.keyCode){
            case 38:
            case 87:
                key = 'up';
                break;
            case 39:
            case 68:
                key = 'right';
                break;
            case 40:
            case 83:
                key = 'down';
                break;
            case 37:
            case 65:
                key = 'left';
                break;
            default:
                key = '';
        }
        func(key);
    });
}

5.1.3 循环事件

在Canvas中,一般使用requestAnimationFrame()方法来实现循环,从而达到动画效果。

requestAnimationFrame()的作用跟setInterval()是一样的,但是两者有一定区别。setInterval()需要手动设置间隔时间才会生效,requestAnimationFrame()不需要手动设置间隔时间,会自动根据浏览器绘制的帧率进行调整。

语法:

代码语言:javascript复制
(function frame(){
    window.requestAnimationFrame(frame);
    cxt.clearRect(0, 0, cnv.width, cnv.height);
    ...
})();

示例:移动的小球

代码语言:javascript复制
//下面代码放置前面文章 my-canvas.vue 组件的 methods 对象里

//移动饿小球
moveBall(){
    let x = 0, y = 0;
    let self = this;
    (function frame(){
        requestAnimationFrame(frame);
        self.clearCanvas();
        self.cxtObj.arc(x  , y  , 20, 0, 360 * Math.PI / 180);
        self.cxtObj.fillStyle = '#2fd6f1';
        self.cxtObj.fill();
    })();
},

一般情况下,由于一些变量往往都需要在动画循环中改变,所以变量的初始化都是在动画循环之外。

从这个例子中也初步知道了Canvas动画的原理是:使用requestAnimationFrame()方法不断地清除Canvas,然后重绘图形。

5.2 物理动画

物理动画,简单来说,就是模拟现实世界的一种动画效果。在物理动画中,物体会遵循牛顿运动定律,如射击游戏中打出去的炮弹会随着重力而降落。

5.2.1 三角函数

常见的三角函数有三种:

语法:

在Canvas中,凡是涉及角度都是用“弧度”表示,在实际开发中,推荐写法为:

代码语言:javascript复制
度数 * Math.PI / 180

在三角函数中,我们可以使用反正切函数Math.atan()来求出两个直角边对应的夹角的度数,但是可能会出现有一个度数对应两个夹角的情况:

在Canvas中,我们可以使用反正切函数 Math.atan2() 来求出两个直角边对应的夹角的度数,并且能够准确判断该度数对应的是哪一个夹角。

语法:

代码语言:javascript复制
//x:表示邻边,y:表示对边,x、y都要区分正负
Math.atan2(y, x);

示例:追随鼠标旋转

首先需要一个可供旋转的对象,先定义一个箭头类,专门用于绘制一个箭头:

代码语言:javascript复制
//定义一个箭头类
export function Arrow(x, y, color, angle){
    //箭头中心x坐标,默认为0
    this.x = x || 0;
    //箭头中心y坐标,默认为0
    this.y = y || 0;
    //颜色,默认值为#f09
    this.color = color || '#f09';
    //旋转角度,默认值为0
    this.angle = angle || 0;
}
Arrow.prototype = {
    draw(cxt, arrowData, type){
        cxt.save();
        cxt.translate(this.x, this.y);
        cxt.rotate(this.angle);
        cxt.beginPath();
        let moveToObj = arrowData.moveToObj;
        let lineToArr = arrowData.lineToArr;
        cxt.moveTo(moveToObj.x, moveToObj.y);
        lineToArr.forEach(val => cxt.lineTo(val.x, val.y));
        cxt.closePath();
        switch(type){
            case 'stroke':
                cxt.strokeStyle = this.color;
                cxt.stroke();
                break;
            case 'fill':
                cxt.fillStyle = this.color;
                cxt.fill();
                break;
        }
        cxt.restore();
    },
}

然后在my-canvas.vue组件中应用:

代码语言:javascript复制
//my-canvas.vue

<template>
    <div class="container">
        <div class="opt-btn">
            <el-button type="primary" size="mini" v-for="(item, index) in optBtnData" :key="index" @click="item.clickBtnFunc">{{item.text}}</el-button>
        </div>
        <canvas ref="myCanvas" class="my-canvas" width="300" height="300"></canvas>
    </div>
</template>

<script>
    import tools from '@/api/tools'
    import {Arrow} from '@/api/tools'
    
    export default {
        data(){
            return {
                 cnvObj: null,//canvas对象
                 cxtObj: null,//上下文环境对象
                 optBtnData: [// 操作按钮数据
                     {text: '绘制箭头', clickBtnFunc: () => {this.drawArrows(this.arrowsData, this.cxtObj, this.cnvObj)}},
                     //...
                 ],
                 arrowsData: {//绘制箭头数据
                    moveToObj: {x: -20, y: -10},
                    lineToArr: [                        
                        {x: 0, y: -10}, {x: 0, y: -20}, {x: 20, y: 0},                        
                        {x: 0, y: 20}, {x: 0, y: 10}, {x: -20, y: 10}                    
                    ]
                },
                //...
            }
        },
        methods: {
            //绘制箭头
            drawArrows(arrowsData, cxt, cnv){
                let arrow = new Arrow(cnv.width / 2, cnv.height / 2);
                arrow.draw(cxt, arrowsData, 'fill');
                tools.listenMousePosition(cnv, (mouse) => {
                    (function drawFrame(){
                        requestAnimationFrame(drawFrame);
                        cxt.clearRect(0, 0, cnv.width, cnv.height);
                        let dx = mouse.x - cnv.width / 2;
                        let dy = mouse.y - cnv.height / 2;
                        arrow.angle = Math.atan2(dy, dx);
                        arrow.draw(cxt, arrowsData, 'fill');
                    })()
                });
            },
            //...
        }
    }
</script>

示例效果:

实现原理:在动画循环中,每次鼠标移动的时候,计算鼠标当前位置与箭头中心的夹角,然后把这个夹角作为箭头旋转的角度,重绘箭头。

三角函数的应用:

1)两点间的距离

在Canvas中,假设有两点(x1, y1)和(x2, y2),求两点之间距离:

代码语言:javascript复制
dx = x2 - x1;
dy = y2 - y1;
distance = Math.sqrt(dx * dx   dy * dy);

2)圆周运动

在Canvas中,圆周运动共有两种形式,即正圆运动和椭圆运动。

① 正圆运动

语法:

代码语言:javascript复制
//centerX、centerY:表示圆心坐标
//angle:表示一个弧度制的角度
//radius:表示圆的半径
x = centerX   Math.cos(angle) * radius;
y = centerY   Math.sin(angle) * radius;

示例:小球绕着圆环旋转

首先需要一个可供旋转的对象,先定义一个球类,专门用于绘制一个球:

代码语言:javascript复制
//定义一个球类
export function Ball(x, y, radius, color){
    //小球中心的x坐标,默认值为0
    this.x = x || 0;
    //小球中心的y坐标,默认值为0
    this.y = y || 0;
    //小球半径,默认值为12
    this.radius = radius || 12;
    //小球颜色,默认值为‘#69f’
    this.color = color || '#f09';
    this.scaleX = 1;
    this.scaleY = 1;
}
Ball.prototype = {
    draw(cxt, type){
        cxt.save();
        cxt.scale(this.scaleX, this.scaleY);
        cxt.beginPath();
        cxt.arc(this.x, this.y, this.radius, 0, 360 * Math.PI / 180);
        cxt.closePath();
        switch(type){
            case 'stroke':
                cxt.strokeStyle = this.color;
                cxt.stroke();
                break;
            case 'fill':
                cxt.fillStyle = this.color;
                cxt.fill();
                break;
        }
        cxt.restore();
    }
}

然后在my-canvas.vue组件中应用:

代码语言:javascript复制
//my-canvas.vue

//...

<script>
    //...
    import {Arrow, Ball} from '@/api/tools'
    
    export default {
        data(){
            return {
                //...
                optBtnData: [// 操作按钮数据
                    //...
                    {text: '移动小球', clickBtnFunc: () => {this.moveBall(this.cxtObj, this.cnvObj)}},
                ],
                //...
            }
        },
        methods: {
            //...
            
            //旋转的小球
            moveBall(cxt, cnv){
                let centerX = cnv.width / 2;
                let centerY = cnv.height / 2;
                let radius = 50, angle = 0;
                let ball = new Ball();
                let circle = new Ball(centerX, centerY, radius);
                (function frame(){
                    requestAnimationFrame(frame);
                    cxt.clearRect(0, 0, cnv.width, cnv.height);
                    cxt.beginPath();
                    circle.draw(cxt, 'stroke');
                    cxt.closePath();
                    cxt.stroke();
                    ball.x = centerX   Math.cos(angle) * radius;
                    ball.y = centerY   Math.sin(angle) * radius;
                    ball.draw(cxt, 'fill');
                    angle  = 0.05;
                })();
            }
        }
    }
</script>

示例效果:

② 椭圆运动

语法:

代码语言:javascript复制
//centerX、centerY:表示圆心坐标
//angle:表示一个弧度制的角度
//radiusX:表示椭圆的x轴半径
//radiusY:表示椭圆的y轴半径
x = cneterX   Math.cos(angle) * radiusX;
y = centerY   Math.sin(angle) * radiusY;

3)波形运动

在Canvas中,根据sin函数作用对象的不同,常见的波形运动有:

① 作用于x轴坐标

当正弦函数sin作用于物体中心的x轴坐标时,物体会进行左右摇摆,类似于水草在水流中左右摇摆。

语法:

代码语言:javascript复制
//(centerX, centerY):表示物体中心坐标
//angle:表示角度(弧度制)
//range:表示振幅
//speed:表示角度改变的大小
x = centerX   Math.sin(angle) * range;
angle  = speed;

示例:左右移动的小球

代码语言:javascript复制
//my-canvas.vue

//...

methods: {
    //...
    
    //左右移动的小球
    moveBall(cxt, cnv){
        let centerX = cnv.width / 2;
        let centerY = cnv.height / 2;
        let ball = new Ball(centerX, centerY);
        let angle = 0, range = 80;
        (function frame(){
            requestAnimationFrame(frame);
            cxt.clearRect(0, 0, cnv.width, cnv.height);
            ball.x = centerX   Math.sin(angle) * range;
            ball.draw(cxt, 'fill');
            angle  = 0.05;
        })()
    }
}

由于sin函数的值是在[-1, 1]之间,在实际开发中我们需要一个较大值的振幅,使得摆动的幅度看起来更加明显一些。

② 作用于y轴坐标

当正弦函数sin作用于物体中心的y轴坐标时,物体运动的轨迹刚好就是sin函数的波形。

语法:

代码语言:javascript复制
y = centerY   Math.sin(angle) * rangle;
angle  = speed;

示例:波形运动

代码语言:javascript复制
//my-canvas.vue

//...

methods: {
    //...
    
    //波形运动
    moveBall(cxt, cnv){
        let centerY = cnv.height / 2;
        let ball = new Ball(0, centerY);
        let angle = 0, range = 40;
        (function frame(){
            requestAnimationFrame(frame);
            cxt.clearRect(0, 0, cnv.width, cnv.height);
            ball.x  = 1;
            ball.y = centerY   Math.sin(angle) * range;
            ball.draw(cxt, 'fill');
            angle  = 0.05;
        })()
    }
}

5.2.2 匀速运动

语法:

代码语言:javascript复制
//object.x:表示物体x轴坐标
//object.y:表示物体y轴坐标
//vx:表示x轴方向的速度大小
//vy:表示y轴方向的速度大小
object.x  = vx;
object.y  = vy;

在匀速运动中,速度有正反方向之分的,正数表示正方向,负数表示反方向。

速度的合成和分解:

语法:

代码语言:javascript复制
//object.x:表示物体x轴坐标
//object.y:表示物体y轴坐标
//vx:表示x轴方向的速度大小
//vy:表示y轴方向的速度大小
//speed: 表示任意方向的速度大小
//angle:表示该速度的方向与x轴正方向的弧度制角度
vx = speed * Math.cos(angle);
vy = speed * Math.sin(angle);
object.x  = vx;
object.y = vy;

对于非x轴方向或非y轴方向上的匀速运动,都采用“分解速度”的方式来实现。

示例:箭头跟随鼠标移动

代码语言:javascript复制
//my-canvas.vue

//...

methods: {
    //...
    
   // 移动的箭头
   moveArrows(arrowsData, cxt, cnv){
       let arrow = new Arrow(cnv.width / 2, cnv.height / 2);
       arrow.draw(cxt, arrowsData, 'fill');
       let speed = 0.01, angle = 0;
       tools.listenMousePosition(cnv, (mouse) => {
           (function drawFrame(){
               requestAnimationFrame(drawFrame);
               cxt.clearRect(0, 0, cnv.width, cnv.height);
               let dx = mouse.x - cnv.width / 2;
               let dy = mouse.y - cnv.height / 2;
               angle = Math.atan2(dy, dx);
               let vx = Math.cos(angle) * speed;
               let vy = Math.sin(angle) * speed;
               arrow.x  = vx;
               arrow.y  = vy;
               arrow.angle = angle;
               arrow.draw(cxt, arrowsData, 'fill');
            })()
        });
    },
}

示例效果:

5.2.3 加速运动

语法:

代码语言:javascript复制
//object.x:表示物体x轴坐标
//object.y:表示物体y轴坐标
//vx:表示x轴方向的速度大小
//vy:表示y轴方向的速度大小
//ax:表示x轴方向加速度
//ay:表示y轴方向加速度
vx  = ax;
vy  = ay;
object.x  = vx;
object.y  = vy;

加速度的合成和分解:

语法:

代码语言:javascript复制
ax = a * Math.cos(angle * Math.PI / 180);
ay = a * Math.sin(angle * Math.PI / 180);
vx  = ax;
vy  = ay;
object.x  = vx;
object.y  = vy;

示例:加速运动的小球

代码语言:javascript复制
//my-canvas.vue

//...

methods: {
    //...
    
    //加速运动的小球
    moveBall(cxt, cnv){
        let ball = new Ball();
        let a = 0.1, vx = 0, vy = 0;
        let ax = a * Math.cos(30 * Math.PI / 180);
        let ay = a * Math.sin(30 * Math.PI / 180);
        (function frame(){
            requestAnimationFrame(frame);
            cxt.clearRect(0, 0, cnv.width, cnv.height);
            ball.x  = vx;
            ball.y  = vy;
            ball.draw(cxt, 'fill');
            vx  = ax;
            vy  = ay;
        })()
    }
}

5.2.4 重力

语法:

代码语言:javascript复制
vy  = gravity;
object.y = vy;

示例:小球从空中自由降落到地面,然后反弹,循环往复,直到它最终速度为0而停止在地面上

代码语言:javascript复制
//my-canvas.vue

//...

methods: {
    //...
    
    //自由降落的小球
    moveBall(cxt, cnv){
        let ball = new Ball(cnv.width / 2, 0);
        let vy = 0, gravity = 0.2, bounce = -0.8;
        (function frame(){
            requestAnimationFrame(frame);
            cxt.clearRect(0, 0, cnv.width, cnv.height);
            ball.y  = vy;
            if(ball.y > cnv.height - ball.radius){
                ball.y = cnv.height - ball.radius;
                vy = vy * bounce;
            }
            ball.draw(cxt, 'fill');
            vy  = gravity;
        })()
    }
}

一般情况下,小球碰到地面都会反弹,由于反弹会有速度损耗,并且小球y轴速度方向会变为反方向,因此需要乘以一个反弹系数bounce,一般取值为-1.0 ~ 0之间的任意数。

Canvas动画循环中注意两点:

1)对于需要不断改变的变量,一般在动画循环之前先定义;

2)对于需要不断改变的变量,一般在动画循环中图形绘制之后才递增或递减。

在实际开发的过程中,任何复杂的效果,都可以采用类似“分而治之”的方法来思考,再复杂的Canvas物理动画,我们从x轴和y轴两个方向来考虑,实现的思路就非常清晰了。

5.2.5 摩擦力

语法:

代码语言:javascript复制
vx *= friction;
vy *= friction;
object.x  = vx;
object.y  = vy;

示例:小球沿任意方向移动

代码语言:javascript复制
//my-canvas.vue

//...

methods: {
    //...
    
    //小球沿任意方向移动
    moveBall(cxt, cnv){
        let ball = new Ball();
        let speed = 8, friction = 0.95;
        let vx = speed * Math.cos(30 * Math.PI / 180);
        let vy = speed * Math.sin(30 * Math.PI / 180);
        (function frame(){
            requestAnimationFrame(frame);
            cxt.clearRect(0, 0, cnv.width, cnv.height);
            ball.x  = vx;
            ball.y  = vy;
            ball.draw(cxt, 'fill');
            vx *= friction;
            vy *= friction;
        })()
    }
}

当物体沿任意方向运动时,如果我们加入摩擦力因素,那么每次都是先把该方向的速度分解为x轴和y轴两个方向的分速度,然后再用分速度乘以摩擦系数。

0 人点赞