Chapter2 - Canvas动画(I)玩转路径和位移 动画原来这麽简单

这个章节呢,同样会以实作为主,在解决问题中带大家学习,逐渐引入JS的语言特性,前面一样会从简单的开始,後面八成会开始越讲越快,若有不明白的欢迎留言询问!

路径和位移

身为程序人,要懂得拆解和观察,可以先想想看"什麽叫做动画",一幅会动的画?还是一个移动中的图像?在Canvas上画画我们已经学会了,上个章节透过不断更新频谱数据,画出来的直方图,也是一种持续变化的动画,因此要思考的是"动"这件事,要满足动,也就是位移,只要能掌握路径,便能掌握动画,咦?最长在动的不就是咱们的滑鼠吗,那今天就用滑鼠带大家学动画吧!

context.drawImage 方法

参数回顾:

  • RATIO: 画布和实际画面的比例
  • WIDTH、HEIGHT: 画布的宽高 (= 实际画面 * RATIO)

让我们沿用上个章节的架构:
https://ithelp.ithome.com.tw/upload/images/20210914/20135197Ze6Ze0yyBz.jpg

let mouseImg = new Image();
mouseImg.src = "../mouse.png";
let mouseX = 0, mouseY = 0;
canvas.addEventListener('mousemove', function (e) {
    let Rect = canvas.getBoundingClientRect();
    mouseX = (e.pageX - Rect.left) * RATIO;
    mouseY = (e.pageY - Rect.top) * RATIO;
}, false);
function MouseAnime(){
    context.drawImage(MouseImg, mouseX, mouseY);
}

pageX, pageY 对应到的实际画面大小,计算其在画布上的相对位置,要乘上我们当时设计的 RATIO

若考量到RWD所用到的touchmove,给大家参考:

if(window.innerWidth < 992)
canvas.addEventListener('touchmove', function (e) {
    let Rect = canvas.getBoundingClientRect();
    mouseX = (e.touches[0].pageX - Rect.left) * RATIO;
    mouseY = (e.touches[0].pageY - Rect.top) * RATIO;
}, false);

用breakpoint来判断,不是mousemove就是touchmove,才不会在移动端重覆执行

题外话:Image类名

这个坑实在有点不想踩,又要拿一些进阶的名词,是後面章节才会提到的,简单来说就是 Image 实例化的物件(mouseImg)并不会在你赋予它图片路径後,就直接读取图片,要是图片太大读取太久,那程序不就会卡住吗?因此它用到的是非同步的方式载入,等到图片载入完毕後,就会触发 load 事件,我们可以自行定义其内容,来决定「当图片载入後,要做什麽反应」。

如果你正心想:「什麽是类名?什麽是实例化?什麽是非同步?」这些不知道没关系,我们未来有机会再娓娓道来,这边只需要知道「如果在设定图片路径後,立刻呼叫drawImage,则浏览器会抛出错误,彷佛mouseImg不存在一样」,因此这边最直觉的解决方案便是:

mouseImg.onload = () => {
    AnimationLoop();
}

在图片载入完毕後,再呼叫动画循环

这样解决虽然很直白,但把整个动画循环的开始,完全依赖於一个图片的读取,实在是不太靠谱,万一这个档案就丢了,读取不到怎麽办呢?因此较好的做法应该是以下这两种:

function MouseAnime(){
    if(MouseImg.complete) context.drawImage(mouseImg, mouseX, mouseY);
}

图片物件身上有complete属性,图片载入时为false,完成後为true,若MouseImg未读取完毕,则暂时不进行绘制

不过有一种状况例外,就是当图片的路径有问题时,由於是非同步载入,这个路径的错误并不会中断整个网页的运行,这固然是一个优点(毕竟网路上的图片总会经常弄丢),只是问题在於由於载入的过程被中断了,MouseImg.complete仍然会是预设值true,并没有进到载入中的状态,直接用上面的代码就会让人有「摁?complete == true已经载入完成了,却还是没有图片」的错觉,於是我们还可以这麽做:

function MouseAnime(){
    if(mouseImg.width) return; // 若宽度为0,表示图片来源未正确设定,直接中断
    
    if(MouseImg.complete) context.drawImage(mouseImg, mouseX, mouseY);
    else context.drawImage(MouseImg, mouseX, mouseY);
}

图片物件建立之初,会预设宽高属性为0,因此我们可以选用width来检查代码

聪明的你是不是也想到了什麽呢?没错,上个章节用到的HTMLMediaElement(aduio),其实也是需要读取的!相比load事件,它的事件名称长了些,叫做loadeddata。那当初为什麽程序没出错?可以说我们运气好,也可以说我们的架构设计的还不错,这些细节的部份在最後的章节,将会一口气对整个架构进行细节的调整。

回归正传

於是乎,这样就有一个基本的图形会随时在滑鼠的位置了,不过它的贴图位置是以坐标点为左上角,因此会发现滑鼠总是在图案的左上角,因此若要置中,可以用第二种写法来设定宽高,并把图片偏移左上,使中心刚好为滑鼠座标,这边为了方便,以长宽1:1的图形举例:

function MouseAnime(){
    let size = WIDTH * 0.1;
    context.drawImage(mouseImg, mouseX-size/2, mouseY-size/2, size, size);
}

考量到RWD,大小也是用WIDTH决定(画布总宽度)

context.drawImage(mouseImg, 0, 0, mouseImg.width, mouseImg.height,
                            mouseX-size/2, mouseY-size/2, size, size);

↑这边也同步提供第三种格式给大家参考,前面四个参数可以让你对图片进行裁切,不过我个人是不建议把事情做得这麽复杂,最好的方式是在制作素材的时候就先设计好宽高的比例,然後取得宽高比,这样,不论是什麽图片,都不用重新做设定:

function MouseAnime(){
    let imgRatio = mouseImg.height / mouseImg.width;
    let w = WIDTH * 0.1;
    let h = WIDTH * 0.1 * imgRatio;
    context.drawImage(mouseImg, mouseX-w/2, mouseY-h/2, w, h);
}

那麽,终於让滑鼠总是待在图片的正中心了,不过,单纯只有这样的话,你会发现,随着鼠标的移动,图片有非常明显的瞬移现象,动画的品质是低劣的,因此我们要来设计一个缓冲的方案了,一开始谈数学公式太复杂了,我们就先思考「希望图案不要立刻到滑鼠的位置」这件事,那是不是就不要马上冲过去,可以让位移变少?

如果这样做...

  • 位移 = 距离 - 缓冲
  • 位移 = 每次移动5格

这几种方案是不是就马上浮现脑袋了,接下来分析他们会遇到的问题,用减法的话,比方说缓冲设为100,那是不是图案最後只会停在距离滑鼠100的地方?那显然不行,如果用固定每次移动5格呢?欸这办法似乎挺不错的,只是当距离超过500,那是不是要移动100次才够呢?似乎再稍微修改一下,就可以得到答案了。

因应上面的问题,我们需要一个方案:「在距离小的时候,移动变少;在距离大的时候,移动变多」,巧了,这意思不就是距离和位移呈正比吗,并且位移要比距离还小,那麽只要这麽设计就大功告成了:

位移 = 距离 / 缓冲

function MouseAnime(){
    cursorX+= (mouseX - cursorX) / 5;
    cursorY+= (mouseY - cursorY) / 5;
    let size = WIDTH * 0.1;
    context.drawImage(mouseImg, cursorX-size/2, cursorY-size/2, size, size);
}

如果有格式控觉得不太好看的话,还有另一种相同效果的版本

cursorX = (cursorX * 4 + mouseX) / 5;
cursorY = (cursorY * 4 + mouseY) / 5;

此时我们发现,图案就像是跟随着滑鼠一样,有自己的运动轨迹,看起来也更加顺畅了,严格来说,它已经不是鼠标了,就是个小跟班

接下来就可以练习尝试做个函式,在加入时间这个概念前,先来制作一个基本的互动模型,让滑鼠点击後,让动画暂停,释放滑鼠後,再让动画继续进行:

let isPathing = false;
canvas.addEventListener('mousedown', () => isPathing = true);
canvas.addEventListener('mousemove', GetMouse);
canvas.addEventListener('mouseup', () => isPathing = false);
canvas.addEventListener('mouseout', () => isPathing = false);
function GetMouse(e){
    let Rect = canvas.getBoundingClientRect();
    mouseX = (e.pageX - Rect.left) * RATIO;
    mouseY = (e.pageY - Rect.top) * RATIO;
}
function MouseAnime(){
    if(!isPathing){
        cursorX+= (mouseX - cursorX) / 5;
        cursorY+= (mouseY - cursorY) / 5;
    }
    let size = WIDTH * 0.1;
    context.drawImage(mouseImg, cursorX-size/2, cursorY-size/2, size, size);
}

在滑鼠按下的时候,先锁住游标图案的座标,让它保持不变,等放开滑鼠时,再一次释放它!有没有很像要喊三二一让赛车冲出去的感觉呢?

接下来我们给它一定的时间,单位为侦数,60侦大约为1秒

let period = 0, timer = 0;
let distanceX = 0, distanceY = 0;
canvas.addEventListener('mouseup', GetDistance);
canvas.addEventListener('mouseout', GetDistance);
function GetDistance(){
    distanceX = (mouseX - cursorX);
    distanceY = (mouseY - cursorY);
    period = 90;
    timer = 90;
    isPathing = false;
}
function MouseAnime(){
    if(!isPathing){
        if(timer > 0){
            cursorX+= distanceX / period;
            cursorY+= distanceY / period;
            timer--;
        }
        else{
            cursorX+= (mouseX - cursorX) / 5;
            cursorY+= (mouseY - cursorY) / 5;
        }
    }
    let size = WIDTH * 0.1;
    context.drawImage(mouseImg, cursorX-size/2, cursorY-size/2, size, size);
}

这样就完成一个等速动画了,可以参考 Demo,接下来几天会陆续更新!

时间也不早了,出个思考题吧

思考题

如果想要让图案启动时,慢慢出发,一边加速一边抵达鼠标的位置,可以怎麽做呢?


<<:  [DAY 1] _ ARM-M0架构MCU之韧体开发教学规划

>>:  Day2

常见攻击(Common Attacks)

高级持久威胁(APT) 多向量多态攻击 拒绝服务 缓冲区溢出 流动码 恶意软件(恶意软件) 偷渡式...

Day27 go-elasticsearch(一)

今日我们将要介绍ES官方提供go-elasticsearch客户端的基本操作。 go-elastic...

[day-13] Python 内建的数值类函式

Python 内建的数值类函式 数值类函式 执行结果 功能 abs(-10) 10 取绝对值 min...

Day2 Redis基础介绍

特性 使用记忆体进行操作 所有资料都透过Key-Value的方式存放在记忆体,在找寻所需资料透过Ha...

Proxmox VE 网路基本设定

按照前一篇的程序安装完成并重开机後,即可开始正式使用 Proxmox VE 系统,请以浏览器连接至...