一、故事的开始,我要一个球
目标:用canvas做一个模拟自由落体运动的小球,小球是有弹性的。
初中物理学过万有引力引力,还记得有 高度,
速度
,重力加速度
,高空抛下后,小球自由落体,假如小球有弹性,那么还会回弹,那么使用canvas模拟一个试验场景吧,
-
搭建html模版,初始化canvas画布、画笔
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title></title> <style> html, body, canvas { margin: 0; padding: 0; height: 100%; width: 100%; } </style> </head> <body> <canvas id="canvas"></canvas> <script> window.onload = () => { const canvas = document.getElementById('canvas'); // 世界有多大舞台就要有多大😄 // ps: 这里的宽高不是css样式层面的宽高,是像素点哦 canvas.width = window.document.body.clientWidth; canvas.height = window.document.body.clientHeight; const ctx = canvas.getContext('2d'); // next do some things // ... } </script> </body> </html>
好了,干干净净的画布就出来啦,
-
开始画球啦, 定义一个类吧 今天不是car类也不是foo类而是
Ball
类。
class Ball { // 初始化的特征 constructor(options = {}) { const { x = 0, // x坐标 y = 0, // y坐标 ctx = null, // 神奇的画笔🖌️ radius = 0, // 球的半径 color = '#000' // 颜色 } = options this.x = x; this.y = y; this.ctx = ctx; this.radius = radius; this.color = color } // 渲染 render() { this.ctx.beginPath(); this.ctx.fillStyle = this.color; // 画圆 this.ctx.arc(this.x, this.y, this.radius, 0, 2 * Math.PI) this.ctx.fill() } }
-
用这个
Ball
类,生成一个球
window.onload = () => { // ... const ctx = canvas.getContext('2d'); const ball = new Ball({ ctx, x: ctx.canvas.width * 0.5, // 在画布的中心位置 y: ctx.canvas.height * 0.5, radius: 20, color: '#66cccc' }) ball.render(); } class Ball { // ... }
小球诞生啦
-
让他动起来,利用好速度和加速度,
那么就要用到requestAnimationFrame
方法,让我们可以在下一帧开始时调用指定函数,
requestAnimationFrame详解。
window.onload = () => { // ... ball.render(); // 循环绘画 const loopDraw = () => { requestAnimationFrame(loopDraw); ball.render(); } loopDraw(); // 滴滴启动动画 } class Ball { // ... }
额~~~~小球还没动,是滴!还需要一个方法在更新小球的位置。继续加工Ball
,加一个update
方法
window.onload = () => { // ... const loopDraw = () => { requestAnimationFrame(loopDraw); // 清除画布,不然可以看见每一帧的运动轨迹,这一块有嚼头,还可以做更炫酷的东西。 ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); ball.render(); ball.update(); // 更新位置 } loopDraw(); } class Ball { // ... this.radius = radius; this.color = color // 速度 this.vy = 0; // 刚开始是静止的 // 加速度 this.gvy = 1; render() { // ... } update() { this.y += this.vy; // 每帧按速度变化的y this.vy += this.gvy; // 每帧速度按照加速度递增 // 触底碰撞检测, 不然球飞出屏幕啦。 if (this.y >= this.ctx.canvas.height - this.radius) { this.y = this.ctx.canvas.height - this.radius; // 回弹就是调整运动方向,那么数值上180度大转弯 this.vy = -this.vy * 0.75;// 速度损耗,粗略模仿受地心引力影响,随意调整到自己喜欢的值,大概。 } } }
小球它动了,它动了!
-
小结
1、动画需要用到
requestAnimationFrame
, 当然也可以用setTimeout
或者setInterval
,来模拟loop。2、绘制下一帧前要清除上一帧的画布,不然上一帧的效果还会保留在画布上。当然你可以保留它,如果需要的话。
3、运动的速度可以理解成,每一帧需要运动的动量,绘制每一帧都是有消耗时间的,而且每一帧的时间还不一定是固定的。根据这个特点动画还可以优化的更流畅。
二、继续玩球
-
让球乱七八糟的运动,假如在太空中,受重力影响忽略不计的话
有y
轴方向的运动经验,加入x
轴的运动,去掉加速度,因为我们在太空啦,
加入全方位碰撞检测
window.onload = () => { // ... } class Ball { // ... // 速度 this.vx = -2; // 这是新成员 this.vy = 2; // 加速度 this.gvx = 0; this.gvy = 0; // 这次我不需要你了 render() { // ... } update() { this.x += this.vx; this.y += this.vy; this.vy += this.gvy; this.vx += this.gvx; // 触顶 if (this.y - this.radius <= 0) { this.y = this.radius this.vy = -this.vy * 0.99 // 随 } // 触底 if (this.y >= this.ctx.canvas.height - this.radius) { if (this.vy <= this.gvy * 2 + this.vy * 0.8) this.vy = 0; this.y = this.ctx.canvas.height - this.radius; this.vy = -this.vy * 0.75; // 便 } // 触右 if (this.x - this.radius <= 0) { this.x = this.radius this.vx = -this.vx * 0.5 // 设 } // 触左 if (this.x + this.radius >= this.ctx.canvas.width) { this.x = this.ctx.canvas.width - this.radius this.vx = -this.vx * 0.5 // 置 } } }
look! 活蹦乱跳的小球,四处碰壁。
-
多球运动
Ball
是一个类, 那么初始化的时候多new几次, 然后给球的速度随机一点
window.onload = () => { // ... const num = 100; let balls = [] // 多姿多彩 const colors = ['#66cccc', '#ccff66', '#ff99cc', '#ff9999', '#666699', '#ff0033', '#FFF2B0']; // 我要100个 for (let i = 0; i < num; i++) { balls.push(new Ball({ ctx, // 随机出现在画布中任何一处 x: Math.floor(Math.random() * ctx.canvas.width), y: Math.floor(Math.random() * ctx.canvas.height), radius: 10, color: colors[Math.floor(Math.random() * 7)] })) } // 循环绘画 const loopDraw = () => { requestAnimationFrame(loopDraw); ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); balls.forEach((ball, index) => { ball.render(); ball.update(); }) } } class Ball { constructor(options = {}) { // ... // 速度 this.vx = (Math.random() - 0.5) * 10; this.vy = (Math.random() - 0.5) * 10; // 加速度 this.gvx = (Math.random() - 0.5) * 0.01; this.gvy = (Math.random() - 0.5) * 0.01 } // ... }
唔~
三、让邻里之间多点联系
邻里只能是在一定范围内的,太远了可不是哦, 那么就需要知道两球之间的距离,计算两点之间的距离,好熟悉啊,一位不知名的热心童鞋瞬间说出了初中(大概)学过的公式
-
连线行动:两球之间用线连起来
Ball
中新的成员登场renderLine
, 画点与点之间的连线;
// js 版本的计算两点距离公式 function twoPointDistance(p1, p2) { let distance = Math.sqrt(Math.pow((p1.x - p2.x), 2) + Math.pow((p1.y - p2.y), 2)); return distance; } window.onload = () => { // ... // 循环绘画 const loopDraw = () => { requestAnimationFrame(loopDraw); ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); balls.forEach((ball, index) => { ball.render(); ball.update(); balls.forEach(ball2 => { const distance = twoPointDistance(ball, ball2) // 排除自己和100像素开外的 if (distance && distance < 100) { ball.renderLine(ball2) } }) }) } } class Ball { // ... render() { // ... } update() { // ... } renderLine(target) { this.ctx.beginPath(); this.ctx.strokeStyle = "ddd"; this.ctx.moveTo(this.x, this.y); this.ctx.lineTo(target.x, target.y); this.ctx.stroke(); } }
-
加一个特殊的纽带
假如我们的颜色各不相同,那由我们共同绘制一条纽带吧
把球变小 数量变多
window.onload = () => { // ... } class Ball { // ... render() { // ... } update() { // ... } renderLine(target) { // ... // 渐变色,由我和target组成 var lingrad = this.ctx.createLinearGradient(this.x, this.y, target.x, target.y); lingrad.addColorStop(0, this.color); lingrad.addColorStop(1, target.color); this.ctx.strokeStyle = lingrad; // ... } }
-
加点拖影 多姿多彩的幻影
window.onload = () => { // ... // 循环绘画 const loopDraw = () => { //... // 替换clearRect, 使上一次的效果透明度变成0.3 ctx.fillStyle = 'rgba(255,255,255,0.3)'; ctx.fillRect(0, 0, canvas.width, canvas.height); // .. } } class Ball { // ... }
-
再来一个圈子,
Ball
中加了个renderCircle
// ... class Ball { // ... renderCircle(target, radius) { this.ctx.beginPath(); this.ctx.strokeStyle = this.color; this.ctx.arc((this.x + target.x) / 2, (this.y + target.y) / 2, radius, 0, 2 * Math.PI); this.ctx.stroke(); } }
-
再来一个。。。算了, 篇幅有限。
四、优化
-
优化到每一帧
每一帧的时间都不一样, 那么不管是x轴还是y轴上的速度,希望在每一毫秒中是一样的, 这样就要获取每一帧的消耗时间,然后调整下update的速度增量, 这样可以说动画更加顺滑
let delayTime = 0; // 上一帧的时间 let lastTime = +new Date; // 循环绘画 const loopDraw = () => { requestAnimationFrame(loopDraw); // 当前时间 const now = +new Date; delayTime = now - lastTime; lastTime = now; if (delayTime > 50) delayTime = 50; balls.forEach((ball, index) => { ball.render(); // 根据时间在update中调整增量 ball.update(delayTime && delayTime); // ... }) } // ... update(delayTime) { // 每一帧的时间都不一样, 那么使用每一毫秒 this.x += this.vx / (delayTime || 1) * 3; this.y += this.vy / (delayTime || 1) * 3; // ... }
-
顺带撸了个帧率监视器
动画是有帧率的,那么就要有一个手段检测它,看看动画是否流畅。小于30帧->红色 大于30->绿色。
假如帧率过低 就可以考虑优化requestAnimationFrame
的中的回调函数,看看是否做了多余的事情。
当然还有很很多优化手段,动画这块我也不是很懂。就不班门弄斧了
小插件其中的主要绘制方法
// 绘制方法 const FPS = (fpsList) => { ctx.font = '14px serif'; ctx.fillStyle = "#fff" const len = fpsList.length; ctx.fillText(`FPS: ${fpsList[len - 1]}`, 5, 14); ctx.lineWidth = '2' fpsList.forEach((time, index) => { if (time < 30) { ctx.strokeStyle = '#fd5454'; } else { ctx.strokeStyle = '#6dc113'; } ctx.beginPath(); ctx.moveTo(ctx.canvas.width - ((len - index) * 2), ctx.canvas.height); ctx.lineTo(ctx.canvas.width - ((len - index) * 2), (ctx.canvas.height - time * 0.5)); ctx.stroke(); }); // 删掉多余的 if (len > 50) { fpsList.shift() } }
最后
基于这些还可以继续拓展,比如做光标在画布上移动,鼠标附近的小球自动连线; 还可以牵引它的运动;小球之间的相互碰撞效果;想法一个个的冒出来,基于一个简单的球类,萌生出各个想法,从画一个圆开始, 到后面各种炫酷的效果,越尝试惊喜越多,这是一个有趣的标签。而且实现这些并没有用到很复杂的API,canvas的 画线moveTo lineTo
画圆arc
等常见的API,加上一点数学或物理知识。正因为这些惊喜,让我在学习之路上不会枯燥。