阅读 233

中秋-canvas-唯美星空

明月几时有?把酒问青天。不知天上宫阙,今夕是何年。我欲乘风归去,又恐琼楼玉宇,高处不胜寒。起舞弄清影,何似在人间。

转朱阁,低绮户,照无眠。不应有恨,何事长向别时圆?人有悲欢离合,月有阴晴圆缺,此事古难全。但愿人长久,千里共婵娟。

值此中秋佳节来临之际,只做了一个星空背景,在此做个分享。效果图如下:

我正在参加中秋创意投稿大赛,详情请看:中秋创意投稿大赛

PC端效果:

Animation.gif

移动端效果:

1631541694919.png

技能点

  1. html 及 canvasApi用来操作canvas进行作画。
  2. es6 中 class 关键字将关键的块进行简单封装提高复用性。

需求分析

最终效果图如上面的图片所示:

  1. 前景为山、月亮以及文字,中景为星空,背景为渐变色作为天空。
  2. 星星至右向左移动,并且有一闪一闪的效果增加用户体验。

**PS:**这里的文字本该加载个其他字体,或者初次加载时应该是一个字一个字的浮现出来。一个是因为懒,另一个是因为UI水平实在差,觉得这样也挺好看。就懒得弄了,有兴趣可以自行优化。[手动憨笑]

思路分析

需求并不麻烦,接触canvas一段时间就可以完成它。因为是demo性质的作品,所以尽可能的会使用一些花里胡哨的东西,常言道“此时不装,更待何时?”

其实也没有多花里胡哨啦!

首先我在默认的html里面呢,使用module的方式,引入es6规范的包,使用new关键字直接画出整个项目,这里主要是考虑在 webpack 的环境下,可以把项目进行微调,使用webpack进行打包成一个独立的js包进行分享。

然后对于需求中提到的几个核心元素:canvas、moon(月亮)、star(星星)以及整个项目 moon-night 都独立封装成 class ,提高代码的复用性,而且都可以单独提出去到其他地方直接使用,当然这是后话了。

最后对所有的类型class 做了一个简单的约束,每个 class 都有各一个 init 、draw、update 的私有方法,分别对应 初始化、绘画、更新三个状态,或者被我吹做三个生命周期函数,对标 vue 的声明周期函数。 [嚣张]

综上所述是整个项目的思路了,下面是代码分析和实现过程。

实现

0. 项目结构

moon-night
|--index.html
|--index.js
|--canvas.class.js
|--star.class.js
|--moon.class.js
复制代码

1. index.html

index.html 中很简单,几乎声明都不用做,只是把 canvas 默认的 margin 和 padding 给清掉,不然会有个白边。

可以看到 script 中的 type = “module” ,在这对script标签里面,可以使用 import 导入es6语法导出的包。

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>温馨夜色</title>
  <style>
    * {
      margin: 0;
      padding: 0;
    }
  </style>
</head>

<body>
  <canvas id="canvas"></canvas>
</body>
<script type="module">
  import MoonNight from './index.js'
  // 实例化一个 MoonNight ,传入两个参数,id,config
  const c = new MoonNight("canvas", {
    isScreen: true,
  })
</script>

</html>
复制代码

-- 上面这个页面,是用 vscode 一键生成的,里面的 viewport 配置和http-equiv有没有对这个项目没有关系。

2. canvas.class.js

canvas.class.js,看到我的命名,应该知道这是个什么样的文件了。

对,就是你想的那样。

声明了一个 class ,名字为Canvas。Canvas这个类可以作为以后所有相关canvas项目的基础类,内部声明一下canvas和canvas.getContext(‘2d’),用以后续真实的绘图类中继承,保证所有的子类都在一个 canvas 上进行操作。

同时,绑定了一个监听函数,用来处理浏览器窗口改变后重新初始化canvas的宽、高。这也是在canvas中经常使用的一个方法。

代码很简单,如下:

export default class Canvas {
  // 接收 两个 参数,id 和 config
  // 目前 config 只接收 一个属性 screen ,用来控制是否跟随窗口变化
  constructor(id, config) {

    if (id !== 0 && !id) {
      throw new Error('id 不能为空!')
    }

    this.cv = document.getElementById(id)
    this.ctx = this.cv.getContext('2d')

    this.config = Object.assign({
      isScreen: true
    }, config)

    this.initSize()
  }

  initSize() {
    this.cv.style.display = "block"
    // 读传入的isScreen的值,根据值判断是否挂载 resize 方法
    if (this.config.isScreen) {
      this.cv.width = window.innerWidth
      this.cv.height = window.innerHeight
      window.addEventListener("resize", this.resizeCanvas.bind(this), false)
    } else {
      this.cv.width = this.cv.parentNode.offsetWidth
      this.cv.height = this.cv.parentNode.offsetHeight
    }

  }

  resizeCanvas() {
    // 重新声明canvas的宽高
    this.cv.width = window.innerWidth
    this.cv.height = window.innerHeight
    // 子类中的 draw 方法
    this.draw()
  }

}
复制代码

关键聊一下resizeCanvas中的 this.draw() 方法,这里的this的指向在canvas被继承之后会发生变化,即和子类中的this指向一致。所以,init方法被写为initSize,避免在继承后init被重写。

3. index.js (moonNight.class.js)

index.js 其实也就是 moonNight.class.js。结合上面 canvas 文件,这个文件最后导出一个默认的类:MoonNight。

MoonNight 继承自 Canvas ,所以可以直接使用 cv 和 ctx,而且项目中所有的项目都是在这个类中实现的。

MoonNight 除了一开始说的三个基础方法之外,还声明了两个方法,画文字、画天空、画山。而且方法都在draw中执行。

这里有一个小知识点,就是在canvas中,后面画的东西,在canvas中重叠的时候,后面画的会自动覆盖在前面画的元素中,同理和 css 的 z-index 属性相似。所以要注意在 draw 中,各个方法的执行顺序,应该是结合效果图,从最下面往上画。

"use script"

// 引入 各个包
import Canvas from "./canvas.class.js"
import Star from './star.class.js'
import Moon from "./moon.class.js"

// 默认导出 MoonNight 类,并继承自 Canvas
export default class MoonNight extends Canvas {
  constructor(id, config) {
    // super 继承父类
    super(id, config)

    // config 为传入的参数,这里还没有什么用,只有传入 canvas 的 isScreen
    this.baseConf = Object.assign({ isScreen: true }, config)
    
    // 声明 moon 成员
    this.moon = null

    // 填充一个随机数组成的 星星 列表
    // 星星的数量为 宽度的五分之一
    this.starPoints = Array(parseInt(this.cv.width / 5)).fill({}).map(item => {
      return {
        x: parseInt(Math.random() * this.cv.width), // 星星的坐标 x
        y: parseInt(Math.random() * this.cv.height * .6), // 星星的坐标 y
        size: parseInt(Math.random() * 10 + 5), // 星星的大小
        big: Math.random() > .5 ? true : false // 一个Boolean值,表示星星下一次变大还是变小
      }
    })

    // 初始化 MoonNight,并开始绑定 动画
    this.init()
    this.t = setInterval(this.update.bind(this), 1000 / 30)
  }

  // init 方法
  init() {
    console.log('MoonNight::: init!');
    this.draw()
  }

  // 绘画 方法
  draw() {
    // 挨个画各个元素
    this.drawSky()
    // 画星星,将每个坐标转化为 实例化后的 星星
    this.stars = this.starPoints.map(item => new Star(this.ctx, item))
    this.drawMountain()
    // 复制 moon 为 Moon 对象
    this.moon = new Moon(this.cv, this.ctx)
    this.drawText()
  }

  // 画 文字
  drawText() {
    let fontSize = this.cv.width > this.cv.height ? this.cv.width * .02 : this.cv.height * .02
    console.log('MoonNight::: drawText!');
    this.ctx.beginPath()
    this.ctx.font = `${fontSize}px bold 楷体`;
    this.ctx.fillStyle = "#F0F003";
    this.ctx.textAlign = "center";
    this.ctx.textBaseline = "middle";
    // 绘制文字(参数:要写的字,x坐标,y坐标)
    this.ctx.fillText("但愿人长久,", this.cv.width * .7, this.cv.height * .16);
    this.ctx.fillText("千里共婵娟。", this.cv.width * .7, this.cv.height * .16 + fontSize + 10);
    this.ctx.closePath()
  }

	// 画 天空
  drawSky() {
    console.log('MoonNight::: drawSky!');
    let grad = this.ctx.createLinearGradient(0, 0, 0, this.cv.height)
    grad.addColorStop(0, "#011447");
    grad.addColorStop(.2, "#011447");
    grad.addColorStop(1, "#958E78");
    this.ctx.fillStyle = grad;
    this.ctx.fillRect(0, 0, this.cv.width, this.cv.height);
    this.ctx.fill()
  }

  // 画 山
  drawMountain() {
    // 山 各个点
    let points = [
      [0, this.cv.height * (.7)],
      [this.cv.width * .06, this.cv.height * .6],
      [this.cv.width * .09, this.cv.height * .66],
      [this.cv.width * .16, this.cv.height * .5],
      [this.cv.width * .2, this.cv.height * .6],
      [this.cv.width * .22, this.cv.height * .57],
      [this.cv.width * .4, this.cv.height * .8],
      [this.cv.width * .5, this.cv.height * .7],
      [this.cv.width * .6, this.cv.height * .8],
      [this.cv.width * .75, this.cv.height * .75],
      [this.cv.width * .9, this.cv.height * .8],
      [this.cv.width * .95, this.cv.height * .75],
      [this.cv.width, this.cv.height * .8],
      [this.cv.width, this.cv.height]
    ]

    // 山 阴影
    // 阴影 为山的点进行转换
    let yy = points.map(item => {
      return [
        item[0] + 8, item[1] - 1
      ]
    })

    // 先画 阴影,使用画布默认机制
    this.ctx.beginPath()
    this.ctx.fillStyle = 'pink'
    this.ctx.moveTo(0, this.cv.height)
    for (let item of yy) {
      this.ctx.lineTo(item[0], item[1])
    }
    this.ctx.fill()
    this.ctx.closePath()

    // 画山
    this.ctx.beginPath()
    this.ctx.fillStyle = '#1A1401'
    this.ctx.moveTo(0, this.cv.height)
    for (let item of points) {
      this.ctx.lineTo(item[0], item[1])
    }
    this.ctx.fill()
    this.ctx.closePath()
  }

  // 更新 方法
  update() {
    console.log('MoonNight::: update!');
    // 清空画布
    this.ctx.clearRect(0, 0, this.cv.width, this.cv.height)
    this.starPoints = this.starPoints.map(item => {
      return {
        // 更新 x 位置,如果到最左边则直接变最右边去,如果转换为随机数,后续坐标会像左半边聚集
        x: item.x - 1 < 0 ? this.cv.width : item.x - 1,
        y: item.y,
       	// 更新 size,步值为 .5
        size: item.big ? item.size + .5 : item.size - .5,
        // 如果星星大小超过最大值,则开始变小,否则开始变大
        big: item.size > 15 && item.big === true ? false : item.size < 1 && item.big === false ? true : item.big
      }
    })
    // 数据更新后,重新画图
    this.draw()
  }
}

复制代码

4. star.class.js

因为五角星坐标计算比较复杂,为了迅速把星星画出来,所以选用的为四角星。

从上面可以看出来,画星星需要三个关键值:x,y,size,还有一个 big 属性作为 update 的依据。

星星的坐标是这样的:

1631543523414.png

灵魂画手上线,如上图所示,通过传入的 x,y,size,可以整理出来8个顶点,通过8个顶点连线并填充就能得到一个 布灵布灵 的小星星,同时修改 size 的大小,达到星星闪烁的效果。

代码如下:

export default class Star {
  constructor(ctx, config) {
    this.ctx = ctx
    this.config = Object.assign({ x: 100, y: 100, size: 10 }, config)

    this.init()
  }

  init() {
    this.draw()
  }

  draw() {
    this.x = this.config.x
    this.y = this.config.y
    
		// 0.6 为星星的宽高比酸楚,计算得出宽高
    this.w = this.config.size * .6
    this.h = this.config.size

    // 0.05 为中心点到最近的四个点的坐标差值百分比
    this.i = this.config.size * .05

    // 导出 八个点
    this.points = [
      [this.x - this.w / 2, this.y],
      [this.x - this.i, this.y - this.i],

      [this.x, this.y - this.h / 2],
      [this.x + this.i, this.y - this.i],

      [this.x + this.w / 2, this.y],
      [this.x + this.i, this.y + this.i],

      [this.x, this.y + this.h / 2],
      [this.x - this.i, this.y + this.i]
    ]
    
    // 白色的小星星就是这么画出来的
    this.ctx.beginPath()
    this.ctx.fillStyle = '#fff'
    this.ctx.moveTo(this.points[0][0], this.points[0][1])
    for (let point of this.points) {
      this.ctx.lineTo(point[0], point[1])
    }
    this.ctx.fill()
    this.ctx.closePath()
  }

  // 更新方法,其实没用上,下面解释一下
  update() {
    this.config = {
      x: this.config.x--,
      y: this.config.y,
      size: this.config.size
    }
    this.draw()
  }
}


复制代码

解释:

在本项目中,星星的更新,其实应该是在 star 的 update中声明,但是这个项目中卸载了 MoonNight 的 update 方法中。

为什么呢?

因为我个人认为在 MoonNight中直接对数组进行操作,可能比 遍历 每个 star 对象要快,纯直觉,并没有做测试。感兴趣的可以操作一下。

哈哈哈~~~

5. moon.class.js

月亮很简单,因为月亮在这个项目中没有做动作,有的话估计也就是光晕会闪一下。

所以这个类中没有做 update方法,只有 draw。

看过上面的代码后这个几乎没有难度:

export default class Moon {
  constructor(cv, ctx) {
    this.cv = cv
    this.ctx = ctx

    this.init()
  }

  init() {
    this.draw()
  }

  draw() {
    let r = this.cv.width > this.cv.height ? this.cv.width * .05 : this.cv.height * .05
    this.ctx.beginPath()
    this.ctx.fillStyle = '#F0F003'
    this.ctx.shadowOffsetX = 0; // 阴影Y轴偏移
    this.ctx.shadowOffsetY = 0; // 阴影X轴偏移
    this.ctx.shadowBlur = 14; // 模糊尺寸
    this.ctx.shadowColor = '#F0F003'; // 颜色

    this.ctx.arc(this.cv.width * .2, this.cv.height * .2, r, 0, 2 * Math.PI)
    this.ctx.fill()
    // 把 阴影设为0,避免影响到其他地方
    this.ctx.shadowOffsetX = 0; // 阴影Y轴偏移
    this.ctx.shadowOffsetY = 0; // 阴影X轴偏移
    this.ctx.shadowBlur = 0;
    this.ctx.closePath()

    this.ctx.beginPath()
    this.ctx.strokeStyle = '#F2D90B'
    this.ctx.lineWidth = r * .15
    this.ctx.lineCap = 'round'

    this.ctx.arc(
      this.cv.width * .2,
      this.cv.height * .2,
      r * .8,
      15 / 360 * Math.PI,
      170 / 360 * Math.PI);
    this.ctx.stroke()
    this.ctx.closePath()
  }
}

复制代码

总结一下

看到这里,是不是觉得很简单?

这个 demo 本来就很简单,主要是想用一下 使用 class 封装在使用 canvas 过程中的一些实例,所以将一个 js 文件 拆分成了4个,并互相引用,很明显,完全也可以把山、文字、天空都提出去,再进行组装。同理,可以封装一下房子、树、云等元素,并在元素上绑定一些交互方法(点击、按键),用来实现一些其他的需求。

canvas 对刚接触的可能会觉得很绕,其实研究一下很有意思的,尤其是对象钻研算法的时候,可以通过很巧妙的方法将算法实现的过程中每一步都完成展现,下面会粘一下那个很棒的设计!叹为观止!

传送门

太震撼了!我把七大JS排序算法做成了可视化!!!太好玩了!

文章分类
前端