「兔了个兔」用canvas实现像素大战,速来!绘制属于你的新春印记~

5,420 阅读4分钟

我正在参加「兔了个兔」创意投稿大赛,详情请看:「兔了个兔」创意投稿大赛

前言

想必大家应该看过Reddit举办的r/Place,用户可以在一张1000x1000的画布每隔五分钟画一个像素点。

于是乎趁着这次活动准备自己动手写一个。本文将主要介绍像素画布的实现方式,才疏学浅还遗留了不少bug,后端使用的是nodejs与socketio,现学现卖,写的有亿点点糟糕🤕。本来像素点的修改后端是设了时间间隔10秒,画布每秒更新1次,但考虑到上次的作品接口被一个大哥刷了(大哥的嘲讽🤡)

image.png image.png

因为代码都是大家都是可以操作的,并且后端不知道是哪位用户发出的,只能配置Nginx限制ip访问频率,或者查看nginx日志中的ip,直接把ip封了(第一次遇到这种问题也是束手无策了,我只是个前端啊喂😕),但大家都是程序员,懂得都懂,这些操作还是没法阻止的,so我直接摆烂,大家先玩着,看看腾讯云学生机能不能抗住🤕。

7L9WMPGP60(7C3T1)E91T4F.gif

先上代码

ASWD可以对选框进行位移 | 前50x50个像素不可修改 这是之前版本的演示

20230112_162357~1.gif

更新

1.13

  • 修复移动端像素择框偏移问题

2.10

  • 修复缩放偏移问题
  • 新增了移动端的移动拖拽操作

准备工作

首先明确大致需要实现的功能:

  1. 一张200x200的画布,并通过鼠标拖拽,缩放
  2. 鼠标点击获取画布上的像素,记录坐标轴
  3. 一块取色板,记录rgb值
  4. 向后端发送请求,后端接收并对所有用户广播消息更新画布

实现画布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();

image.png

生成好之后先实现整个矩形的拖拽与缩放,最开始我先考虑了canvas中的方法。

image.png

拖拽

由于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的矩形,代码省略
});

v1.gif 第一步基本完成了,然后考虑第二步,如何使用鼠标点击获取到某一个4x4的像素, 在不进行位移缩放操作时,只需通过获取鼠标在canvas中的偏移量再将坐标缩小4倍。但是进行了位移缩放后,计算便会变得繁琐,我们需要计算总位移以及总缩放比再进行相关换算,有可能出现计算误差。稍加思索后,我决定改变思路。 image.png

实现画布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}) `
});

v2.gif

但接下来又会出现一个新的问题

image.png

这是因为在缩放的过程中,图片便会根据 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中的取色板

image.png

但是想偷懒,于是只取了右边的色条,这个色条其实是hsl色彩模式,参考下图

image.png

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

图片.png

选中框是在css中进行了scale操作,而在画布的缩放中scale用了 scale *= 1.3,可能是产生的小数造成了误差(不确定,对移动端很陌生,没弄明白,但是能找到办法解决),我又将画布从200x200改成了800x800,选中的每个单位像素也换成了4x4。

1.20更新

没想到一个小小的css属性炸出了一堆东西,对于scale缩放产生的问题,我一开始用的是transform-origin,但是scale操作后再进行位移会导致元素位置突变,前后写了几份代码感觉就是依托答辩,最后学了站内的两个个现场方案。

  1. juejin.cn/post/696980…
  2. juejin.cn/post/700989…

详细内容请大家阅读这两位大佬的文章