【canvas】彩色倒计时炸裂效果

感觉最近喜欢把以前看到的效果重新实现一遍,以前看到很惊艳的效果,知道原理,但是代码能力不行,勉强写出来也是一团麻,只能作罢。但在心里一直是个疙瘩,感觉不自己实现一遍自己就不会一样。趁着最近不忙,写写练练,算是解开疙瘩。

原理分析

整个效果其实有两部分构成,一部分是倒计时效果,另外一部分就是小球散落效果。将它们联系起来的就是每次数字变化时的位置,我们要讲小球初始位置设置在时间显示位置上,然后让小球做自由落体。

代码实现

其实原理不难,难就难在如何去实现所有在 canvas 中绘制的元素的坐标一致性,如果都用同一个坐标系,那么在计算过程中会很繁琐。这里我们用一个个 stage 来记录元素的坐标系,将绘制动作集中在一起,这样可以不用在绘制每一个元素时都要去留意坐标系是什么,要不要更改坐标系。

<!DOCTYPE html>
<html lang="en">
<head>
	<meta charset="UTF-8">
	<title>彩色倒计时</title>
	<style>
		#canvas {
			width: 800;
			height: 600px;
			border: 1px solid;
		}
		#canvas.hover {
			cursor: pointer;
		}
	</style>
</head>
<body>
	<canvas id="canvas" width="1000" height="600"></canvas>
	<script>
		(function() {
			// 定义数字显示二维数组,9(行) x 5(列)
			var NUM_POINT_MAP = {
				0: [[1, 1, 1, 1, 1],
					[1, 0, 0, 0, 1],
					[1, 0, 0, 0, 1],
					[1, 0, 0, 0, 1],
					[1, 0, 0, 0, 1],
					[1, 0, 0, 0, 1],
					[1, 0, 0, 0, 1],
					[1, 0, 0, 0, 1],
					[1, 1, 1, 1, 1]],

				1: [[0, 0, 0, 0, 1],
					[0, 0, 0, 0, 1],
					[0, 0, 0, 0, 1],
					[0, 0, 0, 0, 1],
					[0, 0, 0, 0, 1],
					[0, 0, 0, 0, 1],
					[0, 0, 0, 0, 1],
					[0, 0, 0, 0, 1],
					[0, 0, 0, 0, 1]],

				2: [[1, 1, 1, 1, 1],
					[0, 0, 0, 0, 1],
					[0, 0, 0, 0, 1],
					[0, 0, 0, 0, 1],
					[1, 1, 1, 1, 1],
					[1, 0, 0, 0, 0],
					[1, 0, 0, 0, 0],
					[1, 0, 0, 0, 0],
					[1, 1, 1, 1, 1]],

				3: [[1, 1, 1, 1, 1],
					[0, 0, 0, 0, 1],
					[0, 0, 0, 0, 1],
					[0, 0, 0, 0, 1],
					[1, 1, 1, 1, 1],
					[0, 0, 0, 0, 1],
					[0, 0, 0, 0, 1],
					[0, 0, 0, 0, 1],
					[1, 1, 1, 1, 1]],

				4: [[1, 0, 0, 0, 1],
					[1, 0, 0, 0, 1],
					[1, 0, 0, 0, 1],
					[1, 0, 0, 0, 1],
					[1, 1, 1, 1, 1],
					[0, 0, 0, 0, 1],
					[0, 0, 0, 0, 1],
					[0, 0, 0, 0, 1],
					[0, 0, 0, 0, 1]],

				5: [[1, 1, 1, 1, 1],
					[1, 0, 0, 0, 0],
					[1, 0, 0, 0, 0],
					[1, 0, 0, 0, 0],
					[1, 1, 1, 1, 1],
					[0, 0, 0, 0, 1],
					[0, 0, 0, 0, 1],
					[0, 0, 0, 0, 1],
					[1, 1, 1, 1, 1]],

				6: [[1, 1, 1, 1, 1],
					[1, 0, 0, 0, 0],
					[1, 0, 0, 0, 0],
					[1, 0, 0, 0, 0],
					[1, 1, 1, 1, 1],
					[1, 0, 0, 0, 1],
					[1, 0, 0, 0, 1],
					[1, 0, 0, 0, 1],
					[1, 1, 1, 1, 1]],

				7: [[1, 1, 1, 1, 1],
					[0, 0, 0, 0, 1],
					[0, 0, 0, 0, 1],
					[0, 0, 0, 1, 0],
					[0, 0, 0, 1, 0],
					[0, 0, 1, 0, 0],
					[0, 0, 1, 0, 0],
					[0, 0, 1, 0, 0],
					[0, 0, 1, 0, 0]],

				8: [[1, 1, 1, 1, 1],
					[1, 0, 0, 0, 1],
					[1, 0, 0, 0, 1],
					[1, 0, 0, 0, 1],
					[1, 1, 1, 1, 1],
					[1, 0, 0, 0, 1],
					[1, 0, 0, 0, 1],
					[1, 0, 0, 0, 1],
					[1, 1, 1, 1, 1]],

				9: [[1, 1, 1, 1, 1],
					[1, 0, 0, 0, 1],
					[1, 0, 0, 0, 1],
					[1, 0, 0, 0, 1],
					[1, 1, 1, 1, 1],
					[0, 0, 0, 0, 1],
					[0, 0, 0, 0, 1],
					[0, 0, 0, 0, 1],
					[1, 1, 1, 1, 1]],
			}

			// 类式继承 extend 函数
			function extend(subClass, superClass) {
				// 用一个空函数引用父类原型
				// 因为是空函数,所以在构造过程中不会出现因参数问题导致的报错
				var F = function() {}
				F.prototype = superClass.prototype
				// 子类继承
				subClass.prototype = new F()
				// 重设 constructor
				subClass.constructor = subClass
				// 存储父类构造函数
				subClass.superClass = superClass
			}

			// 订阅发布者
			function Observer() {
				this.subs = {}
			}
			Observer.prototype.addSub = function(name, func) {
				if (!this.subs[name]) {
	        		this.subs[name] = []
	        	}
	        	this.subs[name].push(func)
			}
			Observer.prototype.fire = function(name, data) {
	        	var listeners = this.subs[name] || []
	        	for (var i = 0, len = listeners.length; i < len; i++) {
	        		listeners[i].call(this, data)
	        	}
	        }

			// 动画舞台
			function Stage(canvas, options) {
				if (options === undefined) {
					throw new Error('Stage class required options arugment')
				}
				// canvas
				this.canvas = canvas
				// context
				this.context = this.canvas.getContext('2d')
				// 子元素集合
				this.children = []
				this.addChildren(options.children || [])

				// 动画判断 flag
				this.isRunning = false

				// 坐标系
				this.width = this.canvas.width
				this.height = this.canvas.height
				this.origin = options.origin || {x: 0, y: 0}
			}
			// 添加子元素
			Stage.prototype.addChild = function(child) {
				child.$parent = this
				this.children.push(child)
			}
			Stage.prototype.addChildren = function(children) {
				for (var i = 0, len = children.length; i < len; i++) {
					this.addChild(children[i])
				}
			}
			// 子元素绘制函数
			Stage.prototype.draw = function() {
	        	for (var i = 0, len = this.children.length; i < len; i++) {
					this.children[i].draw(this.context)
				}
			}
			// 舞台开始动画函数
			Stage.prototype.start = function() {
				if (!this.isRunning) {
					var self = this
					self.isRunning = true
					self.context.save()
					self.context.translate(self.origin.x, self.origin.y)
					var animate = function() {
						if (self.isRunning) {
							self.context.clearRect(-self.origin.x, -self.origin.y, self.width, self.height)
							self.draw()
							window.requestAnimationFrame(animate)
						}
					}
					animate()
				}
			}
			// 舞台停止动画
			Stage.prototype.stop = function() {
				this.context.restore()
				this.isRunning = false
			}

			// 自由运动小球
			function Ball(x, y, r) {
				this.x = x
				this.vx = (function() {
					var v = ~~(Math.random() * 16 - 8)
					return v === 0 ? arguments.callee() : v
				})()
				this.y = y
				this.vy = ~~(Math.random() * 16 - 8)
				this.g = 0.98
				this.r = r
				this.c = this.getColor()
			}
			Ball.prototype.move = function() {
				this.x += this.vx
				this.y += (this.vy *= 0.96)
				this.vy += this.g
			}
			Ball.prototype.draw = function(ctx) {
				ctx.save()
				ctx.translate(this.x, this.y)
				ctx.fillStyle = this.c
				ctx.beginPath()
				ctx.moveTo(0, 0)
				ctx.arc(0, 0, this.r, 0, 2 * Math.PI)
				ctx.closePath()
				ctx.fill()
				ctx.restore()

				this.move()
			}
			Ball.prototype.getBoundingClientRect = function() {
				return {
					left: this.x - this.r,
					right: this.x + this.r,
					top: this.y - this.r,
					bottom: this.y + this.r
				}
			}
			Ball.prototype.getColor = function() {
				return '#' + (~~(Math.random() * 16777215)).toString(16)
			}

			// 小球管理
			function BallsManager(x, y, w, h) {
				this.x = x
				this.y = y
				this.w = w
				this.h = h
				this.balls = []
			}
			BallsManager.prototype.addBall = function(ball) {
				this.balls.push(ball)
			}
			BallsManager.prototype.addBalls = function(balls) {
				for (var i = 0, len = balls.length; i < len; i++) {
					this.addBall(balls[i])
				}
			}
			BallsManager.prototype.isOut = function(ball) {
				var rect = ball.getBoundingClientRect()
				if (rect.left > this.x + this.w ||
					rect.right < this.x) {
					return true
				}
				if (rect.top > this.y + this.h) {
					ball.vy *= -1
				}
				return false
			}
			BallsManager.prototype.draw = function(ctx) {
				var i = 0
				while(true) {
					var ball = this.balls[i]
					if (ball) {
						if (this.isOut(ball)) {
							this.balls.splice(i, 1)
						} else {
							ball.draw(ctx)
							i++
						}
					} else {
						break
					}
				}
			}

			var ballsManager = new BallsManager(0, 0, 1000, 600)


			/**
			 * 0 ~ 9 数字显示对象
			 * @param {[type]} x [对象绘图基准坐标 x]
			 * @param {[type]} y [对象绘图基准坐标 y]
			 * @param {[type]} s [数字每个点宽度]
			 * @param {[type]} num [初始显示数字]
			 */
			function NumberBox(x, y, s, num) {
				NumberBox.superClass.call(this)
				this.x = x
				this.y = y
				this.s = s
				this.n = 2
				this.num = num || 0

				this.points = []

				this.createPoints()
			}
			extend(NumberBox, Observer)
			NumberBox.prototype.draw = function(context) {
				context.save()
				context.translate(this.x, this.y)
				this.drawPoint(context)
				context.restore()
			}
			NumberBox.prototype.drawPoint = function(ctx) {
				ctx.fillStyle = 'red'
				var point
				for (var i = 0, len = this.points.length; i < len; i++) {
					point = this.points[i]
					ctx.moveTo(point.x, point.y)
					ctx.arc(point.x, point.y, point.r, 0, 2 * Math.PI)
				}
				ctx.closePath()
				ctx.fill()
			}
			NumberBox.prototype.createPoints = function() {
				this.points = []
				var numPoint = NUM_POINT_MAP[this.num]

				var row, col
				var step, initX, initY
				for (var y = 0, rows = numPoint.length; y < rows; y++) {
					row = numPoint[y]
					for (var x = 0, cols = row.length; x < cols; x++) {
						col = row[x]
						if (col === 1) {
							step = this.s / this.n
							initX = step / 2 - this.s / 2
							initY = initX
							for (var i = 0; i < this.n; i++) {
								for (var j = 0; j < this.n; j++) {
									this.points.push({
										x: x * this.s + initX + step * i,
										y: y * this.s + initY + step * j,
										r: step / 2 * 0.96
									})
								}
							}
						}
					}
				}
			}
			NumberBox.prototype.getAbsolutePos = function(x, y) {
				x = (function(box) {
					return box.$parent ? arguments.callee(box.$parent) + box.x : box.origin.x
				})(this) + x
				y = (function(box) {
					return box.$parent ? arguments.callee(box.$parent) + box.y : box.origin.y
				})(this) + y
				return {
					x: x,
					y: y
				}
			}
			NumberBox.prototype.createBurstPoints = function() {
				var balls = this.points.map(function(point) {
					var pos = this.getAbsolutePos(point.x, point.y)
					return new Ball(pos.x, pos.y, point.r)
				}, this)
				ballsManager.addBalls(balls)
			}
			NumberBox.prototype.plus = function() {
				this.createBurstPoints()

				if (++this.num > 9) {
					this.fire('carry')
					this.num = 0
				}
				this.fire('change', this.num)
				this.createPoints()
			}

			// 两个点
			function DotBox(x, y, w, h) {
				this.x = x
				this.y = y
				this.w = w
				this.h = h
			}
			DotBox.prototype.draw = function(ctx) {
				ctx.save()
				ctx.translate(this.x, this.y)
				ctx.fillStyle = 'black'
				ctx.beginPath()
				ctx.moveTo(this.w / 2, 0)
				ctx.arc(this.w / 2, 0, this.w / 6, 0, 2 * Math.PI)
				ctx.moveTo(this.w / 2, this.h)
				ctx.arc(this.w / 2, this.h, this.w / 6, 0, 2 * Math.PI)
				ctx.closePath()
				ctx.fill()
				ctx.restore()
			}

			// 时间显示器构造函数
			function TimeBox(time, x, y, w, h) {
				this.x = x
				this.y = y
				this.w = w
				this.h = h

				this.hoursNumBoxs = []
				this.minutesNumBoxs = []
				this.secondsNumBoxs = []
				this.dotBoxs = []

				var date = new Date(time)
				var hours = date.getHours()
				this.createNumberBox(hours, 'hours')
				var minutes = date.getMinutes()
				this.createNumberBox(minutes, 'minutes')
				var seconds = date.getSeconds()
				this.createNumberBox(seconds, 'seconds')
				this.createDotBox()
				this.init()

				var self = this
				window.setInterval(function() {
					self.secondsNumBoxs[0].plus()
				}, 1000)
			}
			TimeBox.prototype.createDotBox = function() {
				var dotBoxWidht = this.w / 14
				var dotBoxHeight = this.h * 0.2
				
				var step = this.w * 4 / 14

				var y = (this.h - dotBoxHeight) / 2

				for (var i = 0; i < 2; i++) {
					var x = i * (step + dotBoxWidht) + step
					this.addChild('dot', new DotBox(x, y, dotBoxWidht, dotBoxHeight))
				}
			}
			TimeBox.prototype.createNumberBox = function(num, type) {
				var numBoxWidth = this.w * 2 / 14
				var numBoxHeight = numBoxWidth / 5 * 9
				var s = numBoxWidth * 0.9 / 5
				
				var step = 0
				var units = num % 10
				var unitsX = numBoxWidth + numBoxWidth * 0.1 / 2

				var tens = ~~(num / 10)
				var tensX = numBoxWidth * 0.1 / 2

				var y = (this.h - numBoxHeight) / 2

				var unitsNumBox
				var tensNumBox

				if (type === 'minutes') {
					step = numBoxWidth * 2 + this.w / 14
				} else if (type === 'seconds') {
					step = numBoxWidth * 2 * 2 + this.w * 2 / 14
				}

				unitsNumBox = new NumberBox(unitsX + step, y, s, units)
				tensNumBox = new NumberBox(tensX + step, y, s, tens)
				this.addChildren(type, [unitsNumBox, tensNumBox])
			}
			TimeBox.prototype.addChild = function(type, child) {
				child.$parent = this
				if (type === 'minutes') {
					this.minutesNumBoxs.push(child)
				} else if (type === 'seconds') {
					this.secondsNumBoxs.push(child)
				} else if (type === 'hours') {
					this.hoursNumBoxs.push(child)
				} else if (type === 'dot') {
					this.dotBoxs.push(child)
				}
			}
			TimeBox.prototype.addChildren = function(type, children) {
				for (var i = 0, len = children.length; i < len; i++) {
					this.addChild(type, children[i])
				}
			}
			TimeBox.prototype.init = function() {
				var self = this
				self.secondsNumBoxs[0].addSub('carry', function() {
					self.secondsNumBoxs[1].plus()
				})
				self.secondsNumBoxs[1].addSub('change', function(val) {
					if (val === 6) {
						this.num = 0
						self.minutesNumBoxs[0].plus()
					}
				})

				self.minutesNumBoxs[0].addSub('carry', function() {
					self.minutesNumBoxs[1].plus()
				})
				self.minutesNumBoxs[1].addSub('change', function(val) {
					if (val === 6) {
						this.num = 0
						self.hoursNumBoxs[0].plus()
					}
				})

				self.hoursNumBoxs[1].addSub('change', function(val) {
					if (val === 2 && self.hoursNumBoxs[0].num === 3) {
						this.num = 0
						self.hoursNumBoxs[0].num = 0
					}
				})
			}
			TimeBox.prototype.draw = function(context) {
				context.save()
				context.translate(this.x, this.y)

				for (var i = 0, len = this.hoursNumBoxs.length; i < len; i++) {
					this.hoursNumBoxs[i].draw(context)
				}
				for (var i = 0, len = this.minutesNumBoxs.length; i < len; i++) {
					this.minutesNumBoxs[i].draw(context)
				}
				for (var i = 0, len = this.secondsNumBoxs.length; i < len; i++) {
					this.secondsNumBoxs[i].draw(context)
				}
				for (var i = 0, len = this.dotBoxs.length; i < len; i++) {
					this.dotBoxs[i].draw(context)
				}
				context.restore()
			}

			var timeBox = new TimeBox(new Date(), 100, 0, 800, 300)
			// 新建舞台
			var canvasEl = document.querySelector('#canvas')
			var stage = new Stage(canvasEl, {
				children: [
					timeBox,
					ballsManager
				],
				origin: {
					x: 0,
					y: 0
				}
			})

			stage.start()
		})()
	</script>
</body>
</html>