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中比比划划了。
虽然实现了但是它也太丑了,没有笔锋也没有粗细变化,怎么能写出好看的福字呢,接下来我们进行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的完整版代码
看效果:
到这其实基本上对绘制以及笔触的模拟都已完成,但毕竟是过年,需要有点过年的气氛,而且关于线宽这些我们也可以将其交给用户,让其自己配置,接下来就是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();
}
}
},
效果如下
在上边的图片中也看到了,我将线宽范围、笔触压力和平滑程度可视化出来方便用户自己配置调节想要的效果,这里简单写了一个原生的双向绑定支持随改随生效。
最后为了烘托一下过年的气氛,我们找一张喜庆的背景图作为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版本的最终代码放在最后了。
最后
就把我写的这不争气的福发出来祝大家新年心想事成福气满满!
源码
<!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>