「新春创意」写个福字送给新年的自己吧!

1,160 阅读4分钟

PK创意闹新春,我正在参加「春节创意投稿大赛」,详情请看:春节创意投稿大赛

前言

我又双叒来参加活动了,新年新气象,作为新年的第一篇文章,必须福气满满,给自己一个好兆头(读书笔记暂时鸽一下,哈哈哈哈)。不知道jym还记不记得去年支付宝的一个写福字的活动,我当时还写了一个自认为很好看的福呢。

前几天突发奇想,身为一个前端人为什么不能自己做一个呢?说干就干!

怎么实现

美其名曰写福,其实就是在浏览器绘制,我们很容易就能想到用canvas实现。我们只需要用canvas捕捉鼠标移动轨迹并将其绘制出来就能实现这样的效果(实现的时候发现是我想的太简单了……)。这就需要我们对canvas的api有足够的了解,这里就不介绍了,想了解的百度一下你就知道~

V1.0实现

首先我们肯定需要一个canvas,为了方便观察,给它加上一个背景色:

<canvas id="canvas" style="background:#ffffcc" width="400" height="500"></canvas>

接下来我们需要定义几个变量并开始实现效果:

  • moveFlag<Boolean>:开始绘制的标志
  • offset<Object>:鼠标的当前位置
  • posList<Array>:鼠标运动的位置集合

绘制出来的线实质上是一个个点的集合,所以我们需要对鼠标运动过的位置加以记录。

首先我们需要定义画笔的颜色并对canvas绑定上鼠标相关事件(移动端可调整为touch相关事件,这里不做编写)。

var moveFlag = false
var offset = {},
    posList = [] 

var canvas = document.getElementById('canvas')
var ctx = canvas.getContext('2d')
ctx.fillStyle = 'rgba(0,0,0,0.3)'

canvas.onmousedown = (e) => {
    downEvent(e)
}
canvas.onmousemove = (e) => {
    moveEvent(e)
}
canvas.onmouseup = (e) => {
    upEvent(e)
}
canvas.onmouseout = (e) => {
    upEvent(e)
}

鼠标按下事件的逻辑就是修改标志为true,清空位置集合并保存当前鼠标位置,抬起和移出事件无非就是修改标志为false,实现的重点在移动事件,稍后重点讲解。

function downEvent(e) {
    moveFlag = true
    posList = []
    offset = getPos(e)
}
function upEvent(e) {
    moveFlag = false
}

鼠标按下时使用了一个工具函数获取位置:

function getPos(e) {
    return {
        x: e.clientX - canvas.offsetLeft,
        y: e.clientY - canvas.offsetTop
    }
}

鼠标移动时我们都要干什么,获取当前鼠标位置,将两次鼠标位置移动的距离置入集合,并根据移动距离绘制无数个点以构成线,然后将当前点变成鼠标移动终点位置。

function moveEvent(e) {
    if (!moveFlag) return
    var currentOffset = getPos(e)
    var prevOffset = offset
    var radius = 1

    posList.unshift({
        distance: getDistance(prevOffset, currentOffset),
        time: new Date().getTime()
    })

    var dis = 0,
        time = 0

    for (var i = 0, l = posList.length - 1; i < l; i++) {
        dis += posList[i].distance
        time += posList[i].time - posList[i + 1].time
    }

    offset = currentOffset

    for (var i = 0, l = Math.round(posList[0].distance / 1); i < l + 1; i++) {
        var x = prevOffset.x + (currentOffset.x - prevOffset.x) / l * i
        var y = prevOffset.y + (currentOffset.y - prevOffset.y) / l * i

        ctx.beginPath()
        ctx.arc(x, y, radius, 0, 2 * Math.PI, true)
        ctx.fill()
    }
}

上述代码中,第一个for循环内我们计算了鼠标移动距离,第二个for循环内则是将距离以1为单位分成若干份,每份都画一个圆,形成直线。代码中用到了一个工具函数计算距离:

function getDistance(a, b) {
    return Math.sqrt(Math.pow((b.x - a.x), 2) + Math.pow((b.y - a.y), 2))
}

至此我们的v1.0版本就已经完成了,现在我们可以在canvas中比比划划了。

1.gif 虽然实现了但是它也太丑了,没有笔锋也没有粗细变化,怎么能写出好看的福字呢,接下来我们进行2.0版本改造。

V2.0实现

相比于1.0版本,我们需要给画笔添加更加真实的效果,比如触摸的压力,笔的最大宽度和最小宽度,以及笔的平滑程度。尽最大程度还原真实的使用感觉。

新增如下参数

  • lineMax<Number>:线宽最大值
  • lineMin<Number>:线宽最小值
  • smoothness<Number>:笔触平滑程度
  • linePressure<Number>:笔触压力

如何实现笔触的平滑,我们在计算距离的时候判断当前距离和笔触的平滑程度大小,若距离大于平滑程度则跳出此次循环。

for (var i = 0, l = posList.length - 1; i < l; i++) {
    dis += posList[i].distance
    time += posList[i].time - posList[i + 1].time
    if (dis > smoothness) break; // 新增,保持平滑
}

那我们如何实现笔触的压力效果呢,在使用中,无非就是停留时间长、使劲会让线条更加浑厚粗犷,在代码中,我们可以通过两点之间time的间隔和距离模拟这一使用场景,动态生成圆的半径用来绘制。

var offsetRadius = Math.min((time / dis) * linePressure + lineMin, lineMax) / 2 

这里可以看到,我们利用最小线宽、压力、点的时间和距离模拟了动态圆的半径,并限制住圆半径范围不超过最大线宽。接着在第二个for循环绘制圆中我们就可以使用动态的圆半径来绘制大小不一的圆,模拟粗细有致的平滑笔触。

for (var i = 0, l = Math.round(posList[0].distance / 1); i < l + 1; i++) {
    var x = prevOffset.x + (currentOffset.x - prevOffset.x) / l * i
    var y = prevOffset.y + (currentOffset.y - prevOffset.y) / l * i
    var r = currentRadius + (offsetRadius - currentRadius) / l * i
    ctx.beginPath()
    ctx.arc(x, y, r, 0, 2 * Math.PI, true)
    ctx.fill()
}

2.0的完整代码不摆在这了,下面还有3.0完整版,我最后会附上3.0的完整版代码

看效果:

2.gif

到这其实基本上对绘制以及笔触的模拟都已完成,但毕竟是过年,需要有点过年的气氛,而且关于线宽这些我们也可以将其交给用户,让其自己配置,接下来就是3.0终极版。

V3.0实现

V3.0主要添加了撤销的功能。撤销的实现说白了就是在鼠标移动的时候维护一个笔划的历史数组,点击撤销后去除历史数组中的最末一个对整幅canvas进行重新绘制。

back() {
    history.pop();
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    for (var i = 0; i < history.length; i++) {
        var h = history[i];
        for (var j = 0; j < h.length; j += 3) {
            ctx.beginPath();
            canvas
                .getContext("2d")
                .arc(h[j], h[j + 1], h[j + 2], 0, 2 * Math.PI, true);
            ctx.fill();
        }
    }
},

效果如下

3.gif

在上边的图片中也看到了,我将线宽范围、笔触压力和平滑程度可视化出来方便用户自己配置调节想要的效果,这里简单写了一个原生的双向绑定支持随改随生效。

最后为了烘托一下过年的气氛,我们找一张喜庆的背景图作为canvas的背景绘制上去。

有人会问为什么不直接在css里添加background,其实最开始我也是这么加的,但这种方式在canvas转图片时是不会将背景也作为canvas的一部分的,所以直接将图片绘到canvas上了。

var img = new Image();
img.setAttribute('crossOrigin', 'anonymous');
img.onload = function () {
    ctx.drawImage(img, 0, 0);
}
img.src = './drawBG.jpg';

最后就是添加一个保存逻辑,这个就不列出来了。

大功告成,我们最终形成的就是这样的一个页面,重点在功能实现哈,页面实在是懒得去美化了,至少这个背景看着就很有过年的气氛嘛哈哈哈。美中不足(之一)就是对于笔锋的模拟还是不够到位,但这是我目前能够想到最好的方案了,欢迎小伙伴评论区讨论哈,我一定虚心倾听。v3.0版本的最终代码放在最后了。

image.png

最后

就把我写的这不争气的福发出来祝大家新年心想事成福气满满!

下载 (4).png

源码

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

<head>
	<meta charset="UTF-8" />
	<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
	<style type="text/css">
	</style>
</head>

<body>
	<canvas id="canvas" style="background:#ffffcc" width="400" height="700"></canvas>
	<!-- <img src="./drawBG.jpg" crossorigin="anonymous" id="bg" alt="" style="display: none;"> -->
	<br />
	<div style="position: absolute;top: 10px;left: 420px;">
		线宽范围:<input style="display:inline" type="text" model='lineMin' id="lineMin" /> - <input style="display:inline"
			type="text" model='lineMax' id="lineMax" /><br />
		笔触压力:<input type="text" model='linePressure' id="linePressure" /><br />
		平滑程度:<input type="text" model='smoothness' id="smoothness" /><br />
		<input type="button" id='back' value="撤销" onclick="back()" />
		<input type="button" id='clear' value="清空" onclick="clear()" />
		<input type="button" id='save' value="保存" onclick="save()" />
	</div>

	<script type="text/javascript">
		var moveFlag = false
		var offset = {}, // 当前位置
			posList = [] // 运动位置集合

		var drawHistory = [],
			startOffset = null

		var lineMax = 30,
			lineMin = 2,
			linePressure = 3,
			smoothness = 80

		var radius = 0
		var canvas = document.getElementById('canvas')
		var ctx = canvas.getContext('2d')

		var img = new Image();
		img.setAttribute('crossOrigin', 'anonymous');
		img.onload = function () {
			ctx.drawImage(img, 0, 0);
		}
		img.src = './drawBG.jpg';
		ctx.fillStyle = 'rgba(0,0,0,0.3)'

		canvas.onmousedown = (e) => {
			downEvent(e)
		}
		canvas.onmousemove = (e) => {
			moveEvent(e)
		}
		canvas.onmouseup = (e) => {
			upEvent(e)
		}
		canvas.onmouseout = (e) => {
			upEvent(e)
		}

		function downEvent(e) {
			moveFlag = true
			posList = []
			drawHistory.push([])
			console.log(drawHistory);
			startOffset = offset = getPos(e)
		}

		function moveEvent(e) {
			if (!moveFlag) return

			var currentOffset = getPos(e)
			var prevOffset = offset
			var currentRadius = radius

			posList.unshift({
				distance: getDistance(prevOffset, currentOffset),
				time: new Date().getTime()
			})

			var dis = 0,
				time = 0

			for (var i = 0, l = posList.length - 1; i < l; i++) {
				dis += posList[i].distance
				time += posList[i].time - posList[i + 1].time
				if (dis > smoothness) break; // 新增,保持平滑
			}

			var offsetRadius = Math.min((time / dis) * linePressure + lineMin, lineMax) / 2 // 新增,压力控制圆半径
			radius = offsetRadius // 新增
			offset = currentOffset
			if (dis < 7) return;
			if (startOffset) {
				prevOffset = startOffset
				currentRadius = offsetRadius
				startOffset = null
			}

			for (var i = 0, l = Math.round(posList[0].distance / 1); i < l + 1; i++) {
				var x = prevOffset.x + (currentOffset.x - prevOffset.x) / l * i
				var y = prevOffset.y + (currentOffset.y - prevOffset.y) / l * i

				var r = currentRadius + (offsetRadius - currentRadius) / l * i

				ctx.beginPath()
				ctx.arc(x, y, r, 0, 2 * Math.PI, true)
				ctx.fill()

				drawHistory[drawHistory.length - 1].push(x, y, r)
			}
		}

		function upEvent(e) {
			moveFlag = false
		}

		function getPos(e) {
			return {
				x: e.clientX - canvas.offsetLeft,
				y: e.clientY - canvas.offsetTop
			}
		}

		function getDistance(a, b) {
			return Math.sqrt(Math.pow((b.x - a.x), 2) + Math.pow((b.y - a.y), 2))
		}

		function clear() {
			drawHistory = []
			ctx.clearRect(0, 0, canvas.width, canvas.height);
		}

		function back() {
			drawHistory.pop();
			ctx.clearRect(0, 0, canvas.width, canvas.height);
			ctx.drawImage(img, 0, 0);
			for (var i = 0; i < drawHistory.length; i++) {
				var h = drawHistory[i];
				for (var j = 0; j < h.length; j += 3) {
					ctx.beginPath();
					canvas
						.getContext("2d")
						.arc(h[j], h[j + 1], h[j + 2], 0, 2 * Math.PI, true);
					ctx.fill();
				}
			}
		}

		function save() {
			var url = canvas.toDataURL("image/png");

			var oA = document.createElement("a");
			oA.download = ''; // 设置下载的文件名,默认是'下载'
			oA.href = url;
			document.body.appendChild(oA);
			oA.click();
			oA.remove(); // 下载之后把创建的元素删除
		}
		// input双向绑定
		const ngmodel = {
			lineMin,
			lineMax,
			linePressure,
			smoothness
		};
		// 初始化赋值
		const inputs = document.querySelectorAll('input[model]');
		for (let i = 0; i < inputs.length; i++) {
			inputs[i].value = ngmodel[inputs[i].getAttribute('model')]
			inputs[i].addEventListener('keyup', change)

		};
		// input操作赋值
		function change(e) {
			const attr = e.target.getAttribute('model');

			window[attr] = ngmodel[attr] = e.target.value
		}
	</script>
</body>

</html>