我正在参加「兔了个兔」创意投稿大赛,详情请看:「兔了个兔」创意投稿大赛
前言
想必大家应该看过Reddit举办的r/Place,用户可以在一张1000x1000的画布每隔五分钟画一个像素点。
于是乎趁着这次活动准备自己动手写一个。本文将主要介绍像素画布的实现方式,才疏学浅还遗留了不少bug,后端使用的是nodejs与socketio,现学现卖,写的有亿点点糟糕🤕。本来像素点的修改后端是设了时间间隔10秒,画布每秒更新1次,但考虑到上次的作品接口被一个大哥刷了(大哥的嘲讽🤡)
因为代码都是大家都是可以操作的,并且后端不知道是哪位用户发出的,只能配置Nginx限制ip访问频率,或者查看nginx日志中的ip,直接把ip封了(第一次遇到这种问题也是束手无策了,我只是个前端啊喂😕),但大家都是程序员,懂得都懂,这些操作还是没法阻止的,so我直接摆烂,大家先玩着,看看腾讯云学生机能不能抗住🤕。
先上代码
ASWD可以对选框进行位移 | 前50x50个像素不可修改 这是之前版本的演示
更新
1.13
- 修复移动端像素择框偏移问题
2.10
- 修复缩放偏移问题
- 新增了移动端的移动拖拽操作
准备工作
首先明确大致需要实现的功能:
- 一张200x200的画布,并通过鼠标拖拽,缩放
- 鼠标点击获取画布上的像素,记录坐标轴
- 一块取色板,记录rgb值
- 向后端发送请求,后端接收并对所有用户广播消息更新画布
实现画布v1
最开始的思路是,在html中定义了一个800x800的canvas,并在canvas中生成的4x4为像素单位的200x200图形。
ctx.beginPath();
for (let i = 0; i < 200; i++) {
for (let j = 0; j < 200; j++) {
//arr存储随机的rgb值
ctx.fillStyle = arr[i][j]
//每个像素实际上是一个4x4的矩形
ctx.fillRect(j * 4, i * 4, 4, 4);
}
}
ctx.closePath();
生成好之后先实现整个矩形的拖拽与缩放,最开始我先考虑了canvas中的方法。
拖拽
由于translate的绘制的方式是相对上一次图形的位置平移,所以我们需要在mousemove时进行计算。
let x1 = 0;let y1 = 0; let isDragging=false;
canvas.addEventListener("mousedown", (e)=>{
x1 = e.offsetX;
y1 = e.offsetY;
isDragging=true
});
canvas.addEventListener("mouseup", (e)=>{
x1 = 0;y1 = 0;
isDragging=false
});
canvas.addEventListener("mousemove", (e)=>{
if(isDragging){
const x2 = e.offsetX;
const y2 = e.offsetY;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.translate(x2 - x1, y2 - y1);
//重新绘制200x200的矩形,代码省略
x1 = x2;
y1 = y2;
}
})
缩放
scale也是相对上一次图形进行缩放
canvas.addEventListener('wheel', (e)=> {
let scale = 1;
if (e.deltaY < 0) {
scale = 1.1;
} else {
scale = 0.9;
}
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.scale(scale, scale);
//重新绘制200x200的矩形,代码省略
});
第一步基本完成了,然后考虑第二步,如何使用鼠标点击获取到某一个4x4的像素,
在不进行位移缩放操作时,只需通过获取鼠标在canvas中的偏移量再将坐标缩小4倍。但是进行了位移缩放后,计算便会变得繁琐,我们需要计算总位移以及总缩放比再进行相关换算,有可能出现计算误差。稍加思索后,我决定改变思路。
实现画布v2
在css中也有一套变形操作,这次的实现方式是,我们在canvas标签外加一层标签,以这个标签为背景,canvas为200x200的画布(这里将不再以4x4为像素单位)
//html
<div class="wrapper">
<canvas id="canvas" width="200" height="200"></canvas>
</div>
//画布生成方式
ctx.beginPath();
for (let i = 0; i < 200; i++) {
for (let j = 0; j < 200; j++) {
ctx.fillStyle = arr[i][j]
ctx.fillRect(j , i, 1, 1);
}
}
ctx.closePath();
接下来我们需要将之前实现的拖拽缩放的代码略加修改,这里更换了偏移量计算方式,同时加入了以鼠标为中心点进行缩放,并且无需在拖拽缩放进行画布的更新。
canvas.addEventListener("mousedown", (e) => {
initialX = e.clientX - xOffset;
initialY = e.clientY - yOffset;
isDragging = true;
});
canvas.addEventListener("mouseup", () => {
initialX = currentX;
initialY = currentY;
isDragging = false;
});
canvas.addEventListener("mousemove", (e) => {
if (isDragging) {
currentX = e.clientX - initialX;
currentY = e.clientY - initialY;
xOffset = currentX;
yOffset = currentY;
canvasCtrl.style.left = `${currentX}px`
canvasCtrl.style.top = `${currentY}px`
}
})
canvas.addEventListener('wheel', (e)=> {
const x = e.offsetX;
const y = e.offsetY;
e.deltaY < 0 ? scale += 1.5 : scale -= 0.5;
scale > 60 ? scale = 60 : '';
scale < 2 ? scale = 2 : '';
canvas.style.transformOrigin = `${x}px ${y}px`
canvas.style.transform = `translate3d(${x}px,${y}px, 0) scale(${scale}) `
});
但接下来又会出现一个新的问题
这是因为在缩放的过程中,图片便会根据 image-rendering 指定的算法,缩小或放大到新尺寸。
在canvas的样式中加入image-rendering:pixelated;便能解决。
获取坐标
实现完画布后,开始实现鼠标点击获取画布上的像素,记录坐标轴,因为在之前我们将canvas直接设置成了200x200的大小,所以可以通过getImageData方法拿到鼠标的偏移量便可以获取像素点数据
canvas.addEventListener("click", function (e) {
const x = e.offsetX;
const y = e.offsetY;
const pixel = Array.from(ctx.getImageData(x, y, 1, 1).data);
});
取色板
在取色板的实现上一开始我考虑了ps中的取色板
但是想偷懒,于是只取了右边的色条,这个色条其实是hsl色彩模式,参考下图
pal.beginPath();
for (var i = 0; i < 700; i++) {
for (var j = 0; j < 200; j++) {
pal.fillStyle = 'hsla(' + i * 360 / 700 + ', 100%,' + j * 100 / 200 + '%, ' + 1 + ')';
pal.fillRect(j * 1, i * 1, 1, 1);
}
}
取色的事件与画布拖拽高度相似,就不多赘述了。
发送请求与更新画布
关于socketio这块内容可以参考我的这篇文章云烟花
这里说一下为什么对imageData.data逐一赋值,因为getImageData.data的类型是Uint8ClampedArray,而后端传回来的是Arrary。
//发送请求
btn.addEventListener("click", function (e) {
if (afterColor.style.backgroundColor) {
let data = [...sendXY, afterColor.style.backgroundColor];
socket.emit("pixel", data);
} else {
alert("未选择颜色");
}
//更新画布
socket.on("update", (e) => {
if (typeof e == "string") {
alert(e);
} else {
let imageData = ctx.getImageData(e[0], e[1], 1, 1);
imageData.data[0] = e[2][0];
imageData.data[1] = e[2][1];
imageData.data[2] = e[2][2];
ctx.putImageData(imageData, e[0], e[1]);
ctx.drawImage(canvas, 0, 0);
}
});
总结
本文只是对局部功能与代码进行了探讨,其余部分的可以看看完整代码,可能有不同或者更好的方法。在编码过程中遇到了不少问题,尤其是开头提到的这三个
- 移动端暂时没法用,因为还有事件未实现
- 缩放还存在一点bug
- 像素的选择上有点呆,点击像素的下边与右边会选中相邻的像素
移动端的缩放事件需要花时间学习(移动端这块还没接触到)。缩放的问题其实对变换操作的底层不够了解,translate、scale、rotate、skew都是通过matrix矩阵变换实现的。第三个问题毫无头绪。在后端上参考了reddit的几位开发者写的blog How We Built r/Place,老实说没看太明白,看明白了不会实现。倒是前端方面,其实我在实现完前端才发现的这篇文章,直拍大腿,一开始真没想到直接用Uint8ClampedArray类型进行存储,同时在画布绘制上,reddit也作了更详细的考量,由于文章是在2017年写的,在当时image-rendering的浏览器兼容并不是很好,所以他们采用了drawImage()🤤。
1.13更新
没想到毫无头绪的第三个问题最先解决了,起因是发现了移动端上的bug
选中框是在css中进行了scale操作,而在画布的缩放中scale用了 scale *= 1.3,可能是产生的小数造成了误差(不确定,对移动端很陌生,没弄明白,但是能找到办法解决),我又将画布从200x200改成了800x800,选中的每个单位像素也换成了4x4。
1.20更新
没想到一个小小的css属性炸出了一堆东西,对于scale缩放产生的问题,我一开始用的是transform-origin,但是scale操作后再进行位移会导致元素位置突变,前后写了几份代码感觉就是依托答辩,最后学了站内的两个个现场方案。
详细内容请大家阅读这两位大佬的文章