Canvas如何做支自动填充画笔

1,021 阅读5分钟

这是我参与8月更文挑战的第14天,活动详情查看:8月更文挑战

前言

吾道以一贯之。

——孔子

介绍

本期制作的是一个自动填充带点延迟的画笔,八月最后一天了,就临时创作了这个作品,我们会围绕这个栗子,来一步步实现属于自己的画笔。

具体效果,如下图:

VID_20210830_160429.gif

是不是稍微带点小清新,看起来挺简单的吧,还是分为基础结构,画笔类,绘制事件来讲解,那么我们就开始了~

出发

1.基础结构

<style>
    * {
        padding: 0;
        margin: 0;
    }

    html,
    body {
        width: 100%;
        height: 100vh;
        position: relative;
        overflow: hidden;
        display: flex;
        align-items: center;
        justify-content: center;
        background-color: white;
    }
    #canvas {
        width: 100%;
        height: 100%;
        position: relative;
        z-index: 2;
    }
    #tips {
        position: fixed;
        top: 0;
        bottom: 0;
        left: 0;
        right: 0;
        z-index: 1;
        display: flex;
        align-items: center;
        justify-content: center;
        font-size: 2em;
        font-family: fantasy;
        color: #fc6;
        text-shadow: 2px 2px 2px #000;
    }
    body:hover #tips{
        display: none;
    }
</style>

<body>
    <p id="tips">Click and drag to draw!</p>
    <canvas id="canvas"></canvas>
    <script type="module" src="./app.js"></script>
</body>

我们用module模式去引入js,后面方便模块的引用。

这次加了一句提示语,提示用户怎么使用他。css控制鼠标移入让他消失。

/*app.js*/

/* 填充画笔类先引入稍后会有说明 */
import FillPen from "./js/FillPen.js";

class Application {
  constructor() {
    this.canvas = null;         // 画布
    this.ctx = null;            // 环境
    this.w = 0;                 // 画布宽
    this.h = 0;                 // 画布高
    this.startX = 0;            // 起始x轴坐标
    this.startY = 0;            // 起始y轴坐标
    this.isDown = false;        // 是否按下
    this.dt = 0;                // 周期值
    this.roots = [];            // 画笔数组
    this.root = null;           // 当前画笔
    this.colors = ["#fc6", "#cf6", "#c6f", "#6cf", "#6fc","#f6c"];  // 笔触颜色
    this.init();
  }
  init() {
    // 初始化
    this.canvas = document.getElementById("canvas");
    this.ctx = this.canvas.getContext("2d");
    window.addEventListener("resize", this.reset.bind(this));
    this.reset();
    this.render();
  }
  reset() {
    // 屏幕改变
    this.w = this.canvas.width = this.ctx.width = window.innerWidth;
    this.h = this.canvas.height = this.ctx.height = window.innerHeight;
  }
  render() {
    // 主渲染
    if (navigator.userAgent.match(/(iPhone|iPod|Android|ios)/i)) {
      canvas.addEventListener("touchstart", this._mouseDown.bind(this), false);
      canvas.addEventListener("touchmove", this._mouseMove.bind(this), false);
      canvas.addEventListener("touchend", this._mouseUp.bind(this), false);
    } else {
      canvas.addEventListener("mousedown", this._mouseDown.bind(this), false)
      canvas.addEventListener("mousemove", this._mouseMove.bind(this), false)
      canvas.addEventListener("mouseup", this._mouseUp.bind(this), false)
      canvas.addEventListener("mouseout", this._mouseUp.bind(this), false)
    }
    this.step();
  }
  _mouseDown(e) {
    // 按下
    e.preventDefault();
    if (document.getElementById("tips")) {
      document.getElementById("tips").remove();
    }
    this.isDown = true;
  }
  _mouseMove(e) {
    // 移动
    e.preventDefault();
    const {isDown} = this;
    if (!isDown) return;
  }
  _mouseUp(e) {
    // 抬起
    e.preventDefault();
    this.isDown = false;
  }
  step() {
    // 重绘
    requestAnimationFrame(this.step.bind(this));
  }
}

window.onload = new Application();

这里我们将画布铺满全屏。绑定输入事件后面会用这些事件生成画笔。这里提示语说明一下,一旦点击画布,提示语将永远移除掉,因为我们不想绘制完一部分,鼠标移出画布,提示语与绘制内容同时重叠出现。

2.画笔类

/*FillPen.js*/
class FillPen {
  constructor(options) {
    this.x1 = 0;                    // 起始x轴坐标
    this.x2 = 0;                    // 起始y轴坐标
    this.y1 = 0;                    // 目标x轴坐标
    this.y2 = 0;                    // 目标y轴坐标
    this.level = 5;                 // 尺寸等级
    this.color = "#fc0";            // 颜色
    Object.assign(this, options);
    this.ctx = null;                // 环境
    this.lineData = [];             // 点连接数据
    this.isStart = false;           // 是否开始
    this.isEnd = false;             // 是否结束
    return this;
  }
  render(ctx) {
     // 主渲染
    if (!ctx)
      throw new Error("context is undefined.");
    this.ctx = ctx;
    return this;
  }
  draw() {
    // 绘制
    this.isStart = true;
    const {x1, x2, y1, y2, level, lineData} = this;
    lineData.push({
      level: level - 1,
      x1,
      x2,
      y1,
      y2,
    });
    this._drawLine({level,x1,x2,y1,y2});
  }
  _drawLine({level, x1, x2, y1, y2,color}) {
    // 绘制线段
    const {ctx} = this;
    ctx.lineWidth = level * 8 - 8;
    ctx.lineCap = "round";
    ctx.strokeStyle = level == this.level ? "#000" : color;
    this.ctx.beginPath();
    ctx.moveTo(x1, y1);
    ctx.quadraticCurveTo(x1, y1, x2, y2);
    ctx.stroke();
    this.ctx.closePath();
  }
  update() {
    // 更新绘制点连线
    if (!this.isStart) return;
    let pos = this.lineData.shift();
    if (!pos) return this.isEnd = true;
    else {
      let {level, x1, x2, y1, y2} = pos;
      setTimeout(() => {
        this._drawLine({
          level,
          x1,
          x2,
          y1,
          y2
        });
      },200);
      this.isEnd = false;
    }
  }
}

这里我们目的是这样的:

  1. 每次绘制分为两次,第一次绘制是跟输入设备同步执行,也就是黑色底,而第二次会通个更新实现,所以我们要在第一次绘制的时候把绘制数据存储起来给第二次使用。
  2. 我们之所以分出绘制等级是为了方便得出所用的填充色和大小。甚至想以后搞颜色套娃也方便了其扩展。
  3. 期望输入设备每次会给画笔两个点坐标,分别为起始与结束。我们得到这两个点就可以用canvas api画线来实现两点连线,所以,后面输入设备主要去获取这两个点坐标。
  4. 所有第二次绘制需要等200毫秒延迟,否则执行过快通道会闭合,形成蚯蚓状。
  5. 在更新事件要有两个状态,一个是否开始,一个是否结束,是否开始判断要不要开始绘制,而是否结束会给主逻辑的重绘事件做标记判断,如果完全结束了,就把当前画笔回收掉。

3.绘制事件

我们的画笔和画布都有了,就差画了。

/*app.js*/
_mouseDown(e) {
    e.preventDefault();
    if (document.getElementById("tips")) {
        document.getElementById("tips").remove();
    }
    this.isDown = true;
    this.startX = e.offsetX || e.changedTouches && e.changedTouches[0].clientX;
    this.startY = e.offsetY || e.changedTouches && e.changedTouches[0].clientY;
    this.root = new FillPen({
        parent: null,
        x1: this.startX,
        y1: this.startY,
        color: this.colors[~~(Math.random() * this.colors.length)]
    }).render(this.ctx);
    this.roots.push(this.root)
}
_mouseMove(e) {
    e.preventDefault();
    const {startX, startY, isDown} = this;
    if (!isDown) return;
    let x = e.offsetX || e.changedTouches && e.changedTouches[0].clientX;
    let y = e.offsetY || e.changedTouches && e.changedTouches[0].clientY;
    this.root.x1 = startX;
    this.root.y1 = startY
    this.root.x2 = x;
    this.root.y2 = y;

    this.root.draw();

    this.startX = x;
    this.startY = y;

}
_mouseUp(e) {
    e.preventDefault();
    this.isDown = false;
}

我们点击之后就会生成一个画笔,这个画笔的颜色我们现在给他一个从颜色数组里面的随机色,这个看自己需求,并把它填充到画笔数组里面后面方便其更新填充内容。每次移动坐标,画笔会先绘制出黑色区域,然后将起始坐标更新成现在的坐标等待下一次绘制。

当然,我们因为涉及到移动端触摸,所以,当点击的时候还要兼容触摸。

/*app.js*/
step() {
    requestAnimationFrame(this.step.bind(this));
    this.roots.forEach((root, index) => {
        if (root.isEnd && this.root != root) {
            this.roots.splice(index, 1)
        }
        else root.update();
    })
}

我们这里要把绘制结束的画笔释放掉,没结束的让他更新里面的填充内容。

step() {
    // ...
    if(++this.dt%2==0){
        // ...
    }
}

如果感觉绘制过快也可以通过变量dt去控制他的周期。


大功告成,主要内容到这里就讲完了,在线演示

拓展与延伸

所谓自动填充就是每次手动记录了之前的路径存储的相应点坐标再去做文章,通过这种方式可以延伸出好多神奇的特效。比如喷墨动画,因为有了两点坐标可以计算向量,然后再在其向量上再去套娃,不断生成绘制,会有很多经验的效果,不妨大家自己尝试一下。


八月在这里就画上一个句号吧,本身我也是刚在掘金写东西,真心没底,也想写出自己的特色,并没有使用什么引擎和插件,尽可能的把每一篇的思想带给大家希望产生更多联想而非业务本身的实现。也感谢大家一直的关注,我还会继续努力的,但后面可能更新不会那么频繁了,因为将近一年没有收入了,躺平暂时结束,接下来要准备一下要去搞点钱了。以后有可能在更新canvas的同时,还会开新的板块毕竟游戏引擎,脚手架,webgl水也是很深,好多想要说的,但vue,react可能暂时不会说了,因为经常写写吐了。。。

青山不改,绿水长流,来日方长。后会有期~~

b.webp