用taro3+vue3实现小程序刮刮卡活动效果
基础原理
利用canvas,先画封面图,然后刮的时候,手指刮到哪就去掉这部分封面。露出下面的底图。
底图使用div 可以放背景图也可以放文字, 刮的区域用canvas
<div class="com-scratchcard">
<div class="scratchcard-content" :style="state.backImg" v-html="content"
></div>
<canvas
type="2d"
class="scratchcard"
id="scratchcard-canvas"
@touchStart="touchStart"
@touchmove="touchMove"
@touchEnd="touchEnd"
></canvas>
</div>
流程
- 初始化页面 接口拿到封面信息
- 使用canvas画封面信息
- 用户开始刮, 然后刮到指定比例的时候去调接口拿到抽奖结果(并且扣除用户的抽奖机会)并同时lock锁定,避免重复触发抽奖接口。
- 接口回来后,把结果底图(也就是backImg或content文字)填充好,并在这个时候弹窗提示用户抽奖结果
- 计算比例 是利用cavansContext.getImageData 获取图片像素数据。然后根据刮到的地方去清除计算刮开比例。
- 中奖后重置canvas为原来的封面,并释放lock锁定标记
遇到的问题
刮开的时候,canvas默认的清除是 矩形的,如果页面这样清除区域的话,会有锯齿。
默认: clearRect(x, y , width, height) 只能清除矩形
解决方案
就是分多步去清除,虽然会清除很多次重复区域,但能利用1px的步进清理出没有锯齿的近似圆形的区域。
/**
* (x,y)为要清除的圆的圆心,r为半径,cxt为context
*/
function clearArcFun(x, y, r, cxt) {
var stepClear = 1; // 每次清除的步进器
clearArc(x, y, r);
function clearArc(x, y, radius) {
var calcWidth = radius - stepClear;
var calcHeight = Math.sqrt(radius * radius - calcWidth * calcWidth);
var posX = x - calcWidth;
var posY = y - calcHeight;
var widthX = 2 * calcWidth;
var heightY = 2 * calcHeight;
if (stepClear <= radius) {
cxt.clearRect(posX, posY, widthX, heightY);
stepClear += 1;
clearArc(x, y, radius);
}
}
}
这样看起来就顺畅多了。
其他注意点
- 调用接口的时机(如果接口太慢,可能会导致手刮完了结果还没弹)。
- 如果接口需要用户授权手机号才能调,那需要在调接口前拦截或者在整个组件外层遮罩授权button
- 如果当前业务是做活动(比如10月1号到10月7号做活动),那就需要考虑做时间校验和用户抽奖机会的校验拦截。
完整代码
<template>
<div class="com-scratchcard">
<div
class="scratchcard-content"
:style="state.backImg"
v-html="content"
></div>
<canvas
type="2d"
class="scratchcard"
id="scratchcard-canvas"
@touchStart="touchStart"
@touchmove="touchMove"
@touchEnd="touchEnd"
>
</canvas>
</div>
</template>
<script lang="ts" setup>
import Taro from '@tarojs/taro';
import { isEmpty, throttle } from '@/utils';
import {
withDefaults,
defineProps,
onMounted,
reactive,
defineEmits,
ref,
} from 'vue';
/**
* 组件的设置信息
*/
interface IProp {
content: string;
height: number;
width: number;
coverColor: string;
coverImg: string;
fontSize: string | number;
backgroundColor: string;
ratio: number;
}
const props = withDefaults(defineProps<IProp>(), {
content: '',
height: 164, // 高度
width: 297, // 宽度(375尺寸下)
coverColor: '#C5C5C5',
coverImg: '',
fontSize: 20,
backgroundColor: '#fff',
ratio: 0.5, // 刮开的就抽奖的比例
validator: () => true,
});
const state = reactive<{
luckcard: any;
hasPhone: boolean;
isValidator: boolean;
backImg: {};
prizeResult: any;
}>({
luckcard: null,
hasPhone: false,
isValidator: false,
backImg: {},
prizeResult: {},
});
let requestStatus: '1' | '2' | '3' = '1'; // 1 还没发接口 2已经发接口了 还没返回,3: 发送接口并返回
let canShowPrize = false;
let drawCanvas: any = null;
const pointInfo: any = { startX: -10, startY: -10 };
// 上锁
const lock = ref<boolean>(false);
let drawCtx: any = null;
const emits = defineEmits(['start', 'end', 'catchMove']);
/**
* (x,y)为要清除的圆的圆心,r为半径,cxt为context
*/
function clearArcFun(x, y, r, cxt) {
var stepClear = 1; // 别忘记这一步
clearArc(x, y, r);
function clearArc(x, y, radius) {
var calcWidth = radius - stepClear;
var calcHeight = Math.sqrt(radius * radius - calcWidth * calcWidth);
var posX = x - calcWidth;
var posY = y - calcHeight;
var widthX = 2 * calcWidth;
var heightY = 2 * calcHeight;
if (stepClear <= radius) {
cxt.clearRect(posX, posY, widthX, heightY);
stepClear += 1;
clearArc(x, y, radius);
}
}
}
const clearCanvas = () => {
drawCtx.clearRect(0, 0, props.width, props.height);
};
function _forEach(items, callback) {
return Array.prototype.forEach.call(items, function (item, idx) {
callback(item, idx);
});
}
// 活动结束事件
const activeStop = () => {
emits('end', () => {
clearCanvas();
initCanvas();
});
};
// 游戏触发调用接口
const handleClick = () => {
requestStatus = '2';
emits(
'start',
(prizeResult: any) => {
// 游戏开始
state.backImg = {
background: `url(${prizeResult.activityRewardImageUrl}) center center no-repeat; background-size: contain;`,
};
state.prizeResult = prizeResult;
requestStatus = '3';
if (canShowPrize) {
activeStop();
}
},
(err: string) => {
// 当事件需要停止,返回 err
console.error(err);
state.backImg = {};
lock.value = false;
state.prizeResult = null;
requestStatus = `1`;
canShowPrize = false;
console.log('2oo2o2', 1111);
let timer: any = setTimeout(() => {
clearTimeout(timer);
timer = undefined;
initCanvas();
}, 1500);
},
);
};
// 计算刮开区域占整个刮卡区域的百分比(使用节流,避免触发频率过高)
const calcArea = throttle((ratio = props.ratio) => {
const pixels = drawCtx.getImageData(0, 0, props.width, props.height);
const transPixels: any[] = [];
_forEach(pixels.data, function (_, i) {
const pixel = pixels.data[i + 3];
if (pixel === 0) {
transPixels.push(pixel);
}
});
const temp = transPixels.length / pixels.data.length;
console.log('ratio', ratio, '当前比例:', temp, 'lock.value', lock.value);
if (!lock.value && temp > ratio - 0.1) { // -0.1是为了触发接口
lock.value = true;
handleClick();
}
if (temp > ratio) {
console.log('刮的面积够了就重置刮刮卡');
clearCanvas();
}
}, 600);
/** 手指抬起 */
const touchEnd = () => {
// 计算一下是否达到指定面积比例
calcArea();
// 有接口返回值
if (requestStatus === '3' && state.prizeResult) {
activeStop();
//发送接口,没返回值啥
} else if (requestStatus === '2') {
canShowPrize = true;
}
};
const touchStart = (e) => {
let { x, y } = e.changedTouches[0];
console.log('xy:', x, y);
// xy都 -10 是为了擦除的中心在点击的中心(根据分辨率比例再考虑)
pointInfo.startX = x - 10;
pointInfo.startY = y - 10;
};
const touchMove = (e) => {
let { x, y } = e.changedTouches[0];
clearArcFun(x - 10, y - 10, 20, drawCtx);
pointInfo.startX = x - 10;
pointInfo.startY = y - 10;
calcArea();
};
const findCanvas = function () {
return new Promise((resolve) => {
Taro.createSelectorQuery()
.select('#scratchcard-canvas')
.fields({ node: true, size: true })
.exec((res) => {
if (isEmpty(res) || isEmpty(res[0])) {
resolve({ node: null });
} else {
const { node } = res[0];
resolve(node);
}
});
});
};
// 初始化
const initCanvas = () => {
let left = 0;
let top = 0;
if (props.coverImg) {
var coverImg = drawCanvas.createImage();
coverImg.className = 'cover-image';
coverImg.src = props.coverImg;
coverImg.onload = function () {
drawCtx.drawImage(coverImg, 0, 0, props.width, props.height);
};
} else {
drawCtx.moveTo(left, top);
drawCtx.lineTo(left + props.width, top);
drawCtx.lineTo(left + props.width, props.height);
drawCtx.lineTo(left, props.height);
drawCtx.stroke();
drawCtx.fillStyle = '#ddd';
drawCtx.fill();
}
};
const initDraw = async () => {
drawCanvas = await findCanvas();
drawCtx = drawCanvas.getContext('2d');
drawCanvas.width = props.width;
drawCanvas.height = props.height;
const timer: any = setTimeout(() => {
clearTimeout(timer);
initCanvas();
}, 300);
};
onMounted(() => {
initDraw();
});
defineExpose({
reset: () => {
state.backImg = {};
lock.value = false;
state.prizeResult = null;
requestStatus = `1`;
canShowPrize = false;
initCanvas();
},
});
</script>
<style lang="scss">
.com-scratchcard {
height: 328px;
width: 594px;
position: absolute;
top: 566px;
left: 78px;
.scratchcard {
position: absolute;
left: 0;
top: 0;
z-index: 1;
width: 594px;
height: 328px;
}
.scratchcard-content {
width: 594px;
height: 328px;
text-align: center;
background-size: 100% 100%;
// 这里可以自定义默认背景图
}
}
</style>
结语
刮刮卡效果很常见,本案例也是参考的京东的nutui封装组件。因为不支持小程,才改写了。如果你也在开发小程序时用到刮刮卡效果,可以参考以上代码。
还遗留一个小问题: 就是高清图片画到高清屏幕手机的canvas上晰度会降低。因为开发过程中没有计算分辨率。