当前位置: 首页 > news >正文

干货丨用Canvas画一只会跟着鼠标走的小狗

以前经常看到这种效果:在网页右下角放一个人,然后他的眼珠会跟着鼠标转,效果如下:

这个例子来自于CodePen,它是根据鼠标的位置设置两个眼球的transform: rotate属性做的效果。

这种跟着鼠标移动的小交互一般都比较好玩,所以我突然想到,能不能做一只会跟着鼠标走的小狗,最后的效果如下所示:

我们一步步来实现这个效果。

1. 小狗走的动画

小狗走的动画应该怎么实现呢?如果用一张gif,然后根据鼠标的位置移动这张gif,那么当鼠标停下来小狗不动的效果就做不了,因为gif一直在循环播放代码控制不了这个行为。所以这种简单方案是不可行的。

然后又想到之前用CSS的animation做过这种逐帧动画:

所以就有思路了,小狗的动画也是使用逐帧的动画,并且用JS控制它的播放。

在网上搜罗了一番,还没有人做过类似的动画,不过找到了小狗的素材,这位老兄在教人怎么画行走的动物,刚好可以拿来当做我们的素材,把小狗抠出来:

2. 画一只在原地踏步的小狗

动画的第一步先让小狗原地踏步,即先让这个动画能播放起来,然后再做移动的动画。所谓逐帧动画就是每隔一小会就播放一帧,这样连起来就是在动了。

写一个canvas标签,然后把它固定到页面的底部:

<canvas id="dog-walking" style="position:fixed;left:0;bottom:0"></canvas>

然后设置宽度为页面的100%:

let canvas = document.querySelector("#dog-walking");canvas.width = window.innerWidth;canvas.height = 200;

提示:全文代码部分可左右滑动查看

这样我们就有一个画布了。接着要把图片画让去,先要把图片加载下来,上面我们准备了9张png:0.png ~ 8.png,其中0.png是小狗停住不动的图片,1.png ~ 8.png是小狗在走的图片。

在JS里面怎么加载图片呢,用新建一个Image实例的方式,如下代码所示:

let img = new Image();img.onload = function() {
    beginDraw(img);};img.src = "dog/0.png";

由于图片比较多,我们用类的方式组织我们的代码,把数据当作类的属性,方便存取。如下代码所示:

class DogAnimation {
    constructor(canvas) {
        canvas.width = window.innerWidth;
        canvas.height = 200;
        // 存放加载后狗的图片
        this.dogPictures = [];
        // 图片目录
        this.RES_PATH = "./dog";
        this.IMG_COUNT = 8;
        this.start();
    }
    start() {
        this.loadResources();
    }
    loadResources() {

    }}let canvas = document.querySelector("#dog-walking");let dogAnimation = new DogAnimation(canvas);dogAnimation.start();

把狗的图片放到dogPictures数组里面,在loadResources里面进行加载,如下代码所示:

// 加载图片loadResources() {
    let imagesPath = []; 
    // 准备图片的src
    for (let i = 0; i <= this.IMG_COUNT; i++) {
        imagesPath.push(`${this.RES_PATH}/${i}.png`);
    }   

    let works = []; 
    imagesPath.forEach(imgPath => {
        // 图片加载完之后触发Promise的resolve
        works.push(new Promise(resolve => {
            let img = new Image();
            img.onload = () => resolve(img);
            img.src = imgPath;
        }));
    }); 

    return new Promise(resolve => {
        // 借助Promise.all知道了所有图片都加载好了
        Promise.all(works).then(dogPictures => {
            this.dogPictures = dogPictures;
            resolve();
        }); 
    }); // 这里再套一个Promise是为了让调用者能够知道处理好了}

这段加载图片的代码借助了Promise,把每张图片的加载都当作一个Promise的任务,统一放到一个数组里面,然后再借助Promise.all就知道所有的任务都完成了。这样就拿到了所有已onload的img对象,然后就可以拿来画了。

在start函数里面添加一个画的函数walk的执行:

async start() {
    // 等待资源加载完
    await this.loadResources();
    this.walk(); }walk() {}

实际上为了画逐帧动画,我们要使用window.requestAnimationFrame,这个函数在浏览器画它自己的动画的下一帧之前会先调一下这个函数,理想情况下,1s有60帧,即帧率为60 fps。因为不管是播放视频还是浏览网页它们都是逐帧的,例如往下滚动网页的时候就是一个滚动的动画,所以浏览器本身也是在不断地在画动画,只是当你的网页停止不动时(且页面没有动画元素),它可能会降低帧率减少资源消耗。

所以代码改成这样:

async start() {
    await this.loadResources();
    // 给下一帧注册一个函数
    window.requestAnimationFrame(this.walk.bind(this));}walk() {
    // 绘制狗的图片 
    
    // 继续给下一帧注册一个函数
    window.requestAnimationFrame(this.walk.bind(this));}

我们使用了一个bind(this),它的作用是让walk函数的执行上下文还是指向当前类的实例。

现在怎么让狗动起来呢?最简单的我们可以每隔0.1s就画一帧,这样就会连起来,形成一个动画,为此我们需要记录上一次画的时间,然后判断当前时间与上一次的时间是否大于0.1s,如果是的话就画下一帧,否则什么也不用干。因为上文提过,1s最多有60帧,每一帧间隔 1s / 60 = 16.67ms。如下代码所示,先在constructor添加几个变量,包括一个记录上一帧时间的变量:

constructor(canvas) {
    this.canvas = canvas;
    this.ctx = canvas.getContext("2d");
    // 记录上一帧的时间
    this.lastWalkingTime = Date.now(); 
    // 记录当前画的图片索引
    this.keyFrameIndex = -1; 
    this.start();}

然后在walk函数里面进行绘制,在画的时候每次画都取下张图片,即下一帧的图片,不断循环:

walk() {
    // 绘制狗的图片,每过100ms就画一张 
    let now = Date.now();
    if (now - this.lastWalkingTime > 100) {
        // 先清掉上一次画的内容
        this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
        // 获取下一张图片的索引
        let keyFrameIndex = ++this.keyFrameIndex % this.IMG_COUNT;
        let img = this.dogPictures[keyFrameIndex + 1]; 
                        // img, sx, sy, swidth, sheight
        this.ctx.drawImage(img, 0, 0, img.naturalWidth, img.naturalHeight,
                // dx = 20, dy, dwidth, dheight
                20, 20, 186, 162); 
        this.lastWalkingTime = now;
    }   
    // 继续给下一帧注册一个函数
    window.requestAnimationFrame(this.walk.bind(this));}

这样我们就有了一只在原地踏步的小狗:

然后让它往前走。

3. 让小狗往前走

上面在drawImage的传参固定dx = 20,如果不断加大这个dx,那么它就往前走了。为此在构造函数里面添加一个变量记录当前的位移,并设置小狗的速度:

constructor(canvas) {
    // 小狗的速度
    this.dogSpeed = 0.1;
    // 小狗当前的位移
    this.currentX = 0;}

然后在walk函数里面计算当前累加的位移:

// 计算位移 = 时间 * 速度let distance = (now - this.lastWalkingTime) * this.dogSpeed;this.currentX += distance;this.ctx.drawImage(img, 0, 0, img.naturalWidth, img.naturalHeight,
        // dx, dy, dwidth, dheight
        this.currentX, 20, 186, 162);

但是这样我们发现小狗走起路来一卡一卡的,不是很连贯:

这个是因为每0.1s画一帧,帧率只有10fps,所以一走起来就不太行了。方法一是让它走慢点,这样可以减缓,但是如果想保持速度甚至提高速度的话,我们得想办法优化一下。

4. 算法优化

考虑到狗的控制参数比较集中,把它们写到一个dog的Object里面:

constructor (canvas) {
    this.dog = {
        // 一步10px
        stepDistance: 10,
        // 狗的速度
        speed: 0.15,
        // 鼠标的x坐标
        mouseX: -1
    };}

主要有两个参数,一个是狗的速度另一个是每一步走的位移,然后计算距离方式变成:

let now = Date.now(); let distance = (now - this.lastWalkingTime) * this.dog.speed;if (distance < this.dog.stepDistance) {
    window.requestAnimationFrame(this.walk.bind(this));
    return;}

每一步至少走10px,如果小于这个数的话就不走了。通过每步的位移和速度这两个参数可以很方便地控制狗走的快慢和帧率,例如把stepDistance改小点,speed提高就会走得比较频繁,能提高帧率,上面设置的帧率是14 fps. 不过帧率低的根本原因还是在于小狗走路的图片较少。

5. 走到鼠标的位置停下

给小狗添加一个停留的位置,包括往前走和往后走的,因为一个是鼠标在图片前面,一个是鼠标在图片的后面,需要区分:

this.dog = {
    // 往前走停留的位置
    frontStopX: -1,
    // 往回走停留的位置,
    backStopX: window.innerWidth,};

然后添加一个记录鼠标位置的函数,主要是监听mousemove事件:

async start () {
    await this.loadResources();
    this.pictureWidth = this.dogPictures[0].naturalWidth / 2;
    this.recordMousePosition();
    window.requestAnimationFrame(this.walk.bind(this));}// 记录鼠标位置recordMousePosition() {
    window.addEventListener("mousemove", event => {
        // 如果没减掉图片的宽度,小狗就跑到鼠标后面去了,因为图片的宽度还要占去空间
        this.dog.frontStopX = event.clientX - this.pictureWidth;
        this.dog.backStopX = event.clientX;
    });}

然后在walk函数里面用一个变量stopWalking表示小狗是否停下来,和一个direct表示小狗的方向:

this.keyFrameIndex = ++this.keyFrameIndex % this.IMG_COUNT;let direct = -1,
    stopWalking = false;// 如果鼠标在狗的前面则往前走if (this.dog.frontStopX > this.dog.mouseX) {
    direct = 1; } // 如果鼠标在狗的后面则往回走else if (this.dog.backStopX < this.dog.mouseX) {
    direct = -1;}// 如果鼠标在狗在位置else {
    stopWalking = true;
    // 如果停住的话用0.png(后面还会加1)
    this.keyFrameIndex = -1;}

如果小狗没有停,计算位置的时候乘以direct:

// 计算位置if (!stopWalking) {
    this.dog.mouseX += this.dog.stepDistance * direct;}

如果小狗停了,则mouseX还是上次的值。

鼠标停留在小狗位置的那段代码可以做个优化,如果鼠标在小狗中间的右边,则方向调整为正,否则为负:

// 如果鼠标在狗在位置else {
    stopWalking = true;
    // 如果鼠标在小狗图片中间的右边,则direct为正,否则为负
    direct = this.dog.backStopX - this.dog.mouseX 
                    > this.pictureWidth / 2 ? 1 : -1; 
    this.keyFrameIndex = -1;}

这样鼠标在小狗左右来回移动时,小狗会转头。

得到小狗的位置和方向之后就是画上去,正方向的还好,反方向的由于没图片,我们通过canvas的翻转flip进行绘制,如下代码所示:

ctx.save();if (direct === -1) {
    // 左右翻转绘制
    ctx.scale(direct, 1);}let img = this.dogPictures[this.keyFrameIndex + 1];let drawX = 0;// 左右翻转绘制的位置需要计算一下drawX = this.dog.mouseX * direct -  
            (direct === -1 ? this.pictureWidth : 0);ctx.drawImage(img, 0, 0, img.naturalWidth, img.naturalHeight,
                drawX, 20, 186, 162);  ctx.restore();

这样基本上就完成了,最后一个问题是小狗初始化位置的摆放,如果你要把它摆在右边的话,那需要把它的方向反转一下,摆在最左边也需要。不然你会发现小狗摆在左边,但它的头朝左了,需要转一下放在右边。

一个完整的Demo:Walking Dog.

——知乎:极乐科技  作者:李银城

推荐阅读

 《高效前端》

Web高效编程与优化实践

ISBN:978-7-111-59021-7

作 者:李银城 著

定 价:89.00元

出版时间:2018/03

内容简介:

资深前端工程师、知乎著名前端专栏作者兼前端类话题优秀答主经验总结;从思想提升和内容修炼两个维度,围绕前端工程师必备的前端技术和编程基础,总结出高效编程和应用优化的34个最佳实践。

点击查看书籍详情

相关文章:

  • 从普通程序员到AI大神,月薪7W的正确打开方式...
  • 你确认自己做的是“数据驱动”吗?
  • Google 发布 TensorFlow.js,将机器学习带上浏览器
  • 首都程序员最不爱运动、成都程序员有房又有车、上海程序员最辛苦……原来我们是这样的程序员!
  • 3月份GitHub上最热门的开源项目
  • Web应用安全七大“致命”错误
  • TIOBE 4 月排行榜:Python 强势增长,背后的主力推动者究竟是谁?
  • 以太坊与比特币的异同
  • 不止 Java,Oracle 向 JavaScript 开炮!
  • 犀牛书,Core java以及Python、机器学习等千本好书尽在当当书香节
  • Coder止步35岁?别闹,这份书单带你走上程序员进阶之路!
  • 天了噜,Java 8 要停止维护了!
  • 利用漏洞更改Google搜索排名
  • 这样的代码才是好代码
  • Python 人气王,JS 比 Java 更受企业青睐,PHP不再是最好的语言
  • #Java异常处理
  • 《网管员必读——网络组建》(第2版)电子课件下载
  • 【Under-the-hood-ReactJS-Part0】React源码解读
  • es6
  • EventListener原理
  • Fundebug计费标准解释:事件数是如何定义的?
  • HTML-表单
  • java B2B2C 源码多租户电子商城系统-Kafka基本使用介绍
  • Just for fun——迅速写完快速排序
  • MobX
  • PHP那些事儿
  • react-core-image-upload 一款轻量级图片上传裁剪插件
  • 利用阿里云 OSS 搭建私有 Docker 仓库
  • 前端设计模式
  • 十年未变!安全,谁之责?(下)
  • 写代码的正确姿势
  • 一份游戏开发学习路线
  • 用element的upload组件实现多图片上传和压缩
  • 智能合约开发环境搭建及Hello World合约
  • # Swust 12th acm 邀请赛# [ E ] 01 String [题解]
  • #FPGA(基础知识)
  • $ git push -u origin master 推送到远程库出错
  • %3cscript放入php,跟bWAPP学WEB安全(PHP代码)--XSS跨站脚本攻击
  • (04)odoo视图操作
  • (2)STM32单片机上位机
  • (2021|NIPS,扩散,无条件分数估计,条件分数估计)无分类器引导扩散
  • (Bean工厂的后处理器入门)学习Spring的第七天
  • (阿里巴巴 dubbo,有数据库,可执行 )dubbo zookeeper spring demo
  • (二)换源+apt-get基础配置+搜狗拼音
  • (附源码)springboot高校宿舍交电费系统 毕业设计031552
  • (附源码)基于SpringBoot和Vue的厨到家服务平台的设计与实现 毕业设计 063133
  • (六)vue-router+UI组件库
  • (数据结构)顺序表的定义
  • (算法二)滑动窗口
  • (原創) 如何優化ThinkPad X61開機速度? (NB) (ThinkPad) (X61) (OS) (Windows)
  • (转)jQuery 基础
  • .NET Project Open Day(2011.11.13)
  • .net 受管制代码
  • .NET/C# 如何获取当前进程的 CPU 和内存占用?如何获取全局 CPU 和内存占用?
  • .NET/C# 使窗口永不获得焦点