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

前言

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

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

怎么实现

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

V1.0实现

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

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

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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,实现的重点在移动事件,稍后重点讲解。

1
2
3
4
5
6
7
8
function downEvent(e) {
   moveFlag = true
   posList = []
   offset = getPos(e)
}
function upEvent(e) {
   moveFlag = false
}

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

1
2
3
4
5
6
function getPos(e) {
   return {
       x: e.clientX - canvas.offsetLeft,
       y: e.clientY - canvas.offsetTop
  }
}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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为单位分成若干份,每份都画一个圆,形成直线。代码中用到了一个工具函数计算距离:

1
2
3
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>:笔触压力

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

1
2
3
4
5
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的间隔和距离模拟这一使用场景,动态生成圆的半径用来绘制。

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

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

1
2
3
4
5
6
7
8
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进行重新绘制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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上了。

1
2
3
4
5
6
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

源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
<!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>

「新春创意」写个福字送给新年的自己吧!
https://moewang0321.github.io/2022/01/10/「新春创意」写个福字送给新年的自己吧!/
作者
Moe Wang
发布于
2022年1月10日
许可协议