核心思路(主要是三个步骤)
- 绘制刮刮卡的封面
- 鼠标按下并移动时清除灰色
- 清除后重新绘制新的图
核心点
canvas绘制对象的属性globalCompositeOperation='destination-out'
也称为混合模式
具体实现步骤(只写函数体的部分)
- 获取props 以及定义内部状态
const {
width, // 画布的宽
height, // 画布的高
maskText, // 蒙层的文字
maskColor, // 蒙层文字颜色
maskFont, // 蒙层文字字体大小
maskBackground, // 蒙层的底色
cleanAreaRate = 0.8, // 清除到一定比例时全部清除
contextId = '2d', //
options = {}, //
drawResult, // 清除后展示的内容
} = props;
const canvasRef = React.useRef(null); // canvas元素ref
const [ctx, setCtx] = React.useState(null); // 保存canvas绘画对象
let canDraw: boolean = false; // 判断鼠标是否按下的状态
let showPrize: boolean = true; // 判断可以清除的时机
- 保存绘制对象以及初始化刮刮卡蒙层
React.useEffect(() => { // 保存绘制对象
if (canvasRef.current && width && height) {
setCtx(canvasRef.current.getContext(contextId, {
willReadFrequently: true, ...options
}));
}
}, [width, height]);
const initDraw = React.useCallback(() => { // 绘制蒙层
if (!canvasRef?.current || !ctx) return;
showPrize = false; // 禁止清除
ctx.globalCompositeOperation = 'source-over';
ctx.beginPath();
ctx.fillStyle = maskBackground; // 设置灰色背景色
ctx.fillRect(0, 0, canvasRef.current.width, canvasRef.current.height); // 绘制灰色背景色
ctx.closePath();
ctx.beginPath();
ctx.font = maskFont;
ctx.fillStyle = maskColor;
ctx.textAlign = 'center'; // 文字水平对齐
ctx.textBaseLine = 'middle'; // 文字垂直方向对齐
ctx.fillText(maskText, Number(width) / 2, (Number(height) + 10) / 2); // 设置遮罩的文字
ctx.closePath();
showPrize = true; // 可以清除
}, [ctx, showPrize, width, height]);
React.useEffect(() => { // 组件初始化时执行
initDraw();
}, [initDraw]);
}
- 处理鼠标按下的逻辑
const onStart = (e) => {
if (!canvasRef?.current || !ctx || !showPrize) return;
try {
canDraw = true;
document.getElementsByTagName('body')[0].style.overflow = 'hidden'; // 刮奖时设置当前屏幕不可滚动
drawCircle(e); // 清除的逻辑
} catch (error) {
console.error(error);
canDraw = false;
}
};
- 处理鼠标移动的逻辑(清除)
const onMove = (e) => {
if (!canvasRef?.current || !ctx || !showPrize) return;
try {
if (canDraw && showPrize) {
drawCircle(e); // 清除的逻辑
}
} catch (error) {
console.error(error);
canDraw = false;
}
};
- 处理鼠标抬起的逻辑
const onEnd = React.useCallback(() => {
if (!canvasRef?.current || !ctx || !showPrize) return;
try {
document.getElementsByTagName('body')[0].style.overflow = ''; // 刮奖操作结束设置当前屏幕可滚动
canDraw = false;
} catch (error) {
console.error(error);
canDraw = false;
}
}, [ctx]);
完整代码
import React from 'react';
const Scratch = (props) => {
const {
width, // 画布的宽
height, // 画布的高
maskText, // 蒙层的文字
maskColor, // 蒙层文字颜色
maskFont, // 蒙层文字字体大小
maskBackground, // 蒙层的底色
cleanAreaRate = 0.8, // 清除到一定比例时全部清除
contextId = '2d', //
options = {}, //
drawResult, // 清除后展示的内容
} = props;
const canvasRef = React.useRef(null); // canvas元素ref
const [ctx, setCtx] = React.useState(null); // 保存canvas绘画对象
let canDraw: boolean = false; // 判断鼠标是否按下的状态
let showPrize: boolean = true; // 判断可以清除的时机
React.useEffect(() => { // 保存绘制对象
if (canvasRef.current && width && height) {
setCtx(canvasRef.current.getContext(contextId, {
willReadFrequently: true, ...options
}));
}
}, [width, height]);
const initDraw = React.useCallback(() => {
if (!canvasRef?.current || !ctx) return;
showPrize = false; // 禁止清除
ctx.globalCompositeOperation = 'source-over';
ctx.beginPath();
ctx.fillStyle = maskBackground; // 设置灰色背景色
ctx.fillRect(0, 0, canvasRef.current.width, canvasRef.current.height); // 绘制灰色背景色
ctx.closePath();
ctx.beginPath();
ctx.font = maskFont;
ctx.fillStyle = maskColor;
ctx.textAlign = 'center'; // 文字水平对齐
ctx.textBaseLine = 'middle'; // 文字垂直方向对齐
ctx.fillText(maskText, Number(width) / 2, (Number(height) + 10) / 2); // 设置遮罩的文字
ctx.closePath();
showPrize = true; // 可以清除
}, [ctx, showPrize, width, height]);
// 组件初始化时执行
React.useEffect(() => {
initDraw();
}, [initDraw]);
// 清除以(x,y)为中心的四周边长20px的正方形的遮罩,c时canvas对象
function clearCircle(x, y) {
ctx.globalCompositeOperation = 'destination-out'; // 关键点-混合模式
ctx.moveTo(x, y);
ctx.arc(x, y, 12, 0, Math.PI * 2, true);
ctx.fill();
if (typeof drawResult === 'function') {
drawResult(ctx);
}
}
// 全部清除
function clearAll() {
const canvas = canvasRef.current;
ctx.clearRect(0, 0, canvas.width, canvas.height);
document.getElementsByTagName('body')[0].style.overflow = '';
if (typeof drawResult === 'function') {
drawResult(ctx); // 清除后重新绘制蒙层下面的内容
}
showPrize = false;
}
function getFilledPercentage(_e, w, h) { // 计算当前刮开的面积比例
const { data } = ctx.getImageData(0, 0, w, h); // 获取整个canvas的元素点
let scrapeNum = 0;
// 整个区域的面积
const area = w * h;
for (let i = 3, len = data.length; i < len; i += 4) {
if (data[i] === 0) {
scrapeNum += 1;
}
}
if (scrapeNum > area * cleanAreaRate) { // 达到一定比例后清除所有
clearAll();
return true;
}
return false;
}
function drawCircle(e) { // 清除逻辑
const canvas = canvasRef.current;
let x;
let y;
if (document && document.documentElement) {
x = e.clientX + document.documentElement.scrollLeft;
y = e.clientY + document.documentElement.scrollTop;
}
// 手指到canvas元素左边的距离
const canvX = x - canvas.offsetLeft;
// 手指到canvas元素上边的距离
const canvY = y - canvas.offsetTop;
clearCircle(canvX, canvY); // 清除
getFilledPercentage(e, canvas.width, canvas.height); // 计算刮开的面积
}
const onStart = (e) => { // 鼠标按下
if (!canvasRef?.current || !ctx || !showPrize) return;
try {
canDraw = true;
// 刮奖时设置当前屏幕不可滚动
document.getElementsByTagName('body')[0].style.overflow = 'hidden';
drawCircle(e);
} catch (error) {
console.error(error);
canDraw = false;
}
};
const onMove = (e) => { // 鼠标移动
if (!canvasRef?.current || !ctx || !showPrize) return;
try {
if (canDraw && showPrize) {
drawCircle(e);
}
} catch (error) {
console.error(error);
canDraw = false;
}
};
const onEnd = React.useCallback(() => { // 鼠标抬起
if (!canvasRef?.current || !ctx || !showPrize) return;
try {
// 刮奖操作结束设置当前屏幕可滚动
document.getElementsByTagName('body')[0].style.overflow = '';
canDraw = false;
} catch (error) {
console.error(error);
canDraw = false;
}
}, [ctx]);
React.useEffect(() => { // 鼠标抬起时取消清除的动作
window.addEventListener('mouseup', onEnd, false);
return () => {
window.removeEventListener('mouseup', onEnd);
};
}, [onEnd]);
return (
<canvas
style={{ touchAction: 'none' }}
width={`${width}px`}
height={`${height}px`}
ref={canvasRef}
onMouseDown={onStart}
onMouseMove={onMove}
onMouseUp={onEnd}
/>
);
}
demo
export default () => {
const drawResult = (ctx) => {
const img = new Image();
img.src =
'https://img2.baidu.com/it/u=61792293,1592862768&fm=253&fmt=auto&app=120&f=JPEG?w=1366&h=768';
img.crossOrigin = 'true';
img.onload = function () {
console.log('图片加载完');
ctx.globalCompositeOperation = 'destination-over'; // 关键点
ctx.beginPath();
ctx.drawImage(img, 0, 0, 100, 100);
ctx.closePath();
};
};
return (
<Scratch
width={300}
height={200}
maskText="请刮开"
maskColor="#232323"
maskFont="20px"
maskBackground="gray"
drawResult={drawResult}
/>
);
};
目前功能缺陷(有兴趣的可复制代码自行实现)
- 移动端使用未补充,原理是一样的,只是事件不同
- drawResult 方法里画线条之类的会有模糊感,目前仅支持画图片
- 组件只梳理了核心逻辑,参数判断,取消默认事件等问题均未处理