使用canvas,模拟自由落体运动到炫酷桌面背景的故事...

1,765 阅读6分钟

一、故事的开始,我要一个球

目标:用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,加一个updata方法

window.onload = () => {
  // ...
  const loopDraw = () => {
    requestAnimationFrame(loopDraw);
	// 清除画布,不然可以看见每一帧的运动轨迹,这一块有嚼头,还可以做更炫酷的东西。
    ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
    ball.render();
    ball.updata(); // 更新位置
  }
  loopDraw();
}
class Ball {
  // ...
  this.radius = radius;
  this.color = color

  // 速度
  this.vy = 0;  // 刚开始是静止的
  // 加速度
  this.gvy = 1;
  render() {
      // ...
  }
  updata() {
    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() {
      // ...
  }
  updata() {
    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.updata();
   })
}
}
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.updata();
     balls.forEach(ball2 => {
       const distance = twoPointDistance(ball, ball2)
       // 排除自己和100像素开外的
       if (distance && distance < 100) {
         ball.renderLine(ball2)
        }
     })
   })
}
}
class Ball {
 // ...
 render() {
     // ...
 }
 updata() {
   // ...
 }
 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() {
      // ...
  }
  updata() {
    // ...
  }
  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轴上的速度,希望在每一毫秒中是一样的, 这样就要获取每一帧的消耗时间,然后调整下updata的速度增量, 这样可以说动画更加顺滑

  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();
    // 根据时间在updata中调整增量
    ball.updata(delayTime && delayTime);
    // ...
  })
}

// ...
updata(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,加上一点数学或物理知识。正因为这些惊喜,让我在学习之路上不会枯燥。

更新

  • 鼠标的不会迷失在动画中,焦点就是我,2020-11-11 21:30

// 加载图片
const loadImage = (src) => new Promise(resolve => {
  const img = document.createElement('img');
  img.src = src;
  img.onload = () => {
    return resolve(img);
  }
})
window.onload = async () => {
  // ...
  const bg = await loadImage('./media/bg.jpg');
  let mouseBall;
  // ...
  // 循环绘画
  const loopDraw = () => {
   	// ...
    balls.forEach((ball, index) => {
      // ...
      if (mouseBall) {
        const lineMouse = twoPointDistance(ball, mouseBall);
        if (lineMouse && lineMouse < 100) {
          ball.renderLine(mouseBall)
        }
      }
    // ...
    })
  }
  window.addEventListener('mousemove', (e) => {
    mouseBall = new Ball({
      ctx,
      x: e.pageX,
      y: e.pageY,
      radius: 1,
      color: "#fff"
    })
  })
}