6

1. The core api of the scratch music (eraser) effect

ctx.globalCompositeOperation = type;

Set the type of compositing operation to be applied when drawing a new shape.
The type we need to use here is destination-out
image.png
Details of this property: MDN document

2. Basic Scratch function

canvs overlay on the picture

<style>
    body {
      margin: 0;
    }
    img {
      width: 400px;
      height: 300px;
      left: 200px;
      position: absolute;
      z-index: -1;
    }
    canvas {
      margin-left: 200px;
    }
  </style>
  
  <img src="./test.jpg" alt="pic"/>
  <canvas id="canvas" width="400" height="300"></canvas>
<script>
   let canvas = document.querySelector('#canvas');
   let context = canvas.getContext('2d');
   // 绘制涂层
   context.beginPath();
   context.fillStyle = 'grey';
   context.fillRect(0, 0, 400, 300);
   // 监听鼠标移动事件
   canvas.addEventListener('mousemove', (e) => {
    // 当鼠标左键按下&&移动鼠标时,清除鼠标附近涂层
    if (e.which === 1 && e.button === 0) {
      const x = e.clientX, y = e.clientY;
      context.globalCompositeOperation = 'destination-out';
      context.beginPath();
      // 清除以鼠标位置为圆心,半径为10px的圆的范围
      context.arc(x - 200, y, 10, 0, Math.PI * 2);
      context.fill();
    }
   })
  </script>

3. Advanced version of scratch music function

Advanced features:
When clicking, scrape a part of the area with the current position as the center of the circle;
After scraping the x percentage (can be customized), it will display all, and use the animation to gradually fade;
Call the callback method for the first scraping and the callback method after scraping, which can be passed in or not;
Customized text can be displayed on the coating;

First, change to the class form, which is convenient for creating scratching music multiple times.

class Scratch {
      constructor(id, { maskColor = 'grey', cursorRadius = 10 } = {}) {
        this.canvas = document.getElementById('canvas');
        this.context = this.canvas.getContext('2d');
        this.width = this.canvas.clientWidth;
        this.height = this.canvas.clientHeight;
        this.maskColor = maskColor; // 涂层颜色
        this.cursorRadius = cursorRadius; // 光标半径
        this.init();
      }
      init() {
        // 添加涂层
        this.addCoat();
        let bindEarse = this.erase.bind(this);
        this.canvas.addEventListener('mousedown', (e) => {
          // 按下左键
          if (e.which === 1 && e.button === 0) {
            // 擦掉涂层
            this.canvas.addEventListener('mousemove', bindEarse);
          }
        })
        document.addEventListener('mouseup', () => {
          this.canvas.removeEventListener('mousemove', bindEarse);
        })
      }
      addCoat() {
        this.context.beginPath();
        this.context.fillStyle = this.maskColor;
        this.context.fillRect(0, 0, this.width, this.height);
      }
      erase(e) {
        const x = e.clientX, y = e.clientY;
        this.context.globalCompositeOperation = 'destination-out';
        this.context.beginPath();
        this.context.arc(x - this.width / 2, y, this.cursorRadius, 0, Math.PI * 2);
        this.context.fill();
      }
    }
    new Scratch('canvas');

Then, record the mouse position, judge whether to click or click & move the mouse when mouseup, if it is a click, scrape a part of the area with the current position as the center of the circle;

this.canvas.addEventListener('mousedown', (e) => {
      this.posX = e.clientX;
      this.posY = e.clientY;
      ...
})
 document.addEventListener('mouseup', (e) => {
    if (this.posX === e.clientX && this.posY === e.clientY) {
      this.erase(e);
    }
     ...
})

Then, judge whether the scraped area is more than half, if it is to clear the coating;

ImageData ctx.getImageData(sx, sy, sw, sh);

sx: The x coordinate of the upper left corner of the rectangular area of the image data to be extracted.
sy: The y coordinate of the upper left corner of the rectangular area of the image data to be extracted.
sw: The width of the rectangular area of the image data to be extracted.
sh: The height of the rectangular area of the image data to be extracted.

The ImageData object contains the rectangular image data given by the canvas. Can be used to determine whether it is scratched.
Every 4 elements represent the rgba value of a pixel, so it can be judged whether the value of the 4th is less than half of 256 or 128. If it is less than 128, it can be regarded as transparent (scratched).

Clear the contents of the designated area:

void ctx.clearRect(x, y, width, height);
document.addEventListener('mouseup', (e) => {
   this.getScratchedPercentage();
    if (this.currPerct >= this.maxEraseArea) {
        this.context.clearRect(0, 0, this.width, this.height);
    }
})

getScratchedPercentage() {
    const pixels = this.context.getImageData(0, 0, this.width, this.height).data;
    let transparentPixels = 0;
    for (let i = 0; i < pixels.length; i += 4) {
         if (pixels[i + 3] < 128) {
            transparentPixels++;
          }
    }
    this.currPerct = (transparentPixels / pixels.length * 4 * 100).toFixed(2);
}

Then, set the callback method for the first scraping and the callback method after scraping;

constructor(id, { maskColor = 'grey', cursorRadius = 10, maxEraseArea = 50,
    firstEraseCbk = () => { }, lastEraseCbk = () => { } } = {}) {
    ...
    this.firstEraseCbk = firstEraseCbk; // 第一次刮的回调函数
    this.lastEraseCbk = lastEraseCbk; // 刮开的回调函数
}

this.canvas.addEventListener('mousedown', (e) => { 
    if (this.currPerct === 0) {
        this.firstEraseCbk();
    }
})
document.addEventListener('mouseup', (e) => {
    if (this.currPerct >= this.maxEraseArea) {
        this.context.clearRect(0, 0, this.width, this.height);
        this.lastEraseCbk();
    }
})

Then, slowly clear the coating when you scrape it all away, and set the background color gradient effect;
requestAnimationFrame makes the animation smoother
The callback function can pass parameters to the callback function in the form of a closure

document.addEventListener('mouseup', (e) => {
    if (this.currPerct >= this.maxEraseArea) {
        this.done = true;
        requestAnimationFrame(this.fadeOut(255));
         this.lastEraseCbk();
    }
})

fadeOut(alpha) {
    return () => {
          this.context.save();
          this.context.globalCompositeOperation = 'source-in';
          this.context.fillStyle = this.context.fillStyle + (alpha -= 1).toString(16);
          this.context.fillRect(0, 0, this.width, this.height);
          this.context.restore();
          // 到210已经看不到涂层了
          if (alpha > 210) {
            requestAnimationFrame(this.fadeOut(alpha));
          }
     }
}

Then, when the coating is initialized, the custom text will be displayed on the coating;

addCoat() {
        ...
        if (this.text) {
          this.context.font = 'bold 48px serif';
          this.context.fillStyle = '#fff';
          this.context.textAlign = 'center';
          this.context.textBaseline = 'middle';
          this.context.fillText(this.text, this.width / 2, this.height / 2);
        }
}

Complete code

<!DOCTYPE html>
<html lang="en">


<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
    body {
      margin: 0;
    }


    img {
      width: 400px;
      height: 300px;
      left: 200px;
      position: absolute;
      z-index: -1;
    }


    canvas {
      margin-left: 200px;
    }
  </style>
</head>


<body>
  <img src="./test.jpg" alt="pic" />
  <canvas id="canvas" width="400" height="300"></canvas>
  <script>
    class Scratch {
      constructor(id, { maskColor = 'grey', cursorRadius = 10, maxEraseArea = 50, text = '',
        firstEraseCbk = () => { }, lastEraseCbk = () => { } } = {}) {
        this.canvasId = id;
        this.canvas = document.getElementById(id);
        this.context = this.canvas.getContext('2d');
        this.width = this.canvas.clientWidth;
        this.height = this.canvas.clientHeight;
        this.maskColor = maskColor; // 涂层颜色
        this.cursorRadius = cursorRadius; // 光标半径
        this.maxEraseArea = maxEraseArea; // 刮开多少后自动清空涂层
        this.text = text;
        this.firstEraseCbk = firstEraseCbk; // 第一次刮的回调函数
        this.lastEraseCbk = lastEraseCbk; // 刮开的回调函数
        this.currPerct = 0; // 当前刮开多少百分比
        this.done = false; // 是否刮完
        this.init();
      }
      init() {
        // 添加涂层
        this.addCoat();
        let bindEarse = this.erase.bind(this);
        this.canvas.addEventListener('mousedown', e => {
          if (this.done) {
            return;
          }
          this.posX = e.clientX;
          this.posY = e.clientY;
          // 按下左键
          if (e.which === 1 && e.button === 0) {
            // 擦掉涂层
            this.canvas.addEventListener('mousemove', bindEarse);
          }
          if (this.currPerct === 0) {
            this.firstEraseCbk();
          }
        })
        document.addEventListener('mouseup', e => {
          if (this.done) {
            return;
          }
          if (e.target.id !== this.canvasId) {
            return;
          }
          if (this.posX === e.clientX && this.posY === e.clientY) {
            this.erase(e);
          }
          this.canvas.removeEventListener('mousemove', bindEarse);
          this.getScratchedPercentage();
          if (this.currPerct >= this.maxEraseArea) {
            this.done = true;
            requestAnimationFrame(this.fadeOut(255));
            this.lastEraseCbk();
          }
        })
      }
      // 添加涂层
      addCoat() {
        this.context.beginPath();
        this.context.fillStyle = this.maskColor;
        this.context.fillRect(0, 0, this.width, this.height);
        // 绘制涂层上的文字
        if (this.text) {
          this.context.font = 'bold 48px serif';
          this.context.fillStyle = '#fff';
          this.context.textAlign = 'center';
          this.context.textBaseline = 'middle';
          this.context.fillText(this.text, this.width / 2, this.height / 2);
        }
      }
      // 擦除某位置涂层
      erase(e) {
        const x = e.clientX, y = e.clientY;
        this.context.globalCompositeOperation = 'destination-out';
        this.context.beginPath();
        this.context.arc(x - this.width / 2, y, this.cursorRadius, 0, Math.PI * 2);
        this.context.fill();
      }
      // 计算被擦除的部分占全部的百分比
      getScratchedPercentage() {
        const pixels = this.context.getImageData(0, 0, this.width, this.height).data;
        let transparentPixels = 0;
        for (let i = 0; i < pixels.length; i += 4) {
          if (pixels[i + 3] < 128) {
            transparentPixels++;
          }
        }
        this.currPerct = (transparentPixels / pixels.length * 4 * 100).toFixed(2);
      }
      // 清空涂层时淡出效果
      fadeOut(alpha) {
        return () => {
          this.context.save();
          this.context.globalCompositeOperation = 'source-in';
          this.context.fillStyle = this.context.fillStyle + (alpha -= 1).toString(16);
          this.context.fillRect(0, 0, this.width, this.height);
          this.context.restore();
          // 到210已经看不到涂层了
          if (alpha > 210) {
            requestAnimationFrame(this.fadeOut(alpha));
          }
        }
      }
    }
    new Scratch('canvas', { text: '刮一刮', maxEraseArea: 10 });
  </script>
</body>


</html>

河豚学前端
739 声望22 粉丝

一起学前端!wx: hetun_learn