前言
最近口算 PK 火了,本来我只是看了几个帖子图一乐(毕竟我也不会写 python 拉爆小学生)。但是因为工作的缘故,被迫调研了几小时这个功能,于是我萌生了自己动手实现简配版 PK 的想法。本文将拆分口算 PK 的核心功能,并通过纯前端来实现我感兴趣的功能。
拆分核心功能
观察整个 PK 活动功能,我将其核心功能拆分为以下部分:
- 题目生成:最开始的游戏题型是 “20以内比大小”,一共有 10题,后续随着功能拓展提供了算数等题型。
- 答题判断:用户通过手写输入指定符号,系统对答案进行判断,并给出反馈。
- 多人 PK 机制:与真实用户完成匹配,并且根据完成题目的时间快慢,展示胜负结果。
- 排名:完成答题后,根据胜负关系获得积分并积累,展示用户排名。
实现方案
我们将采用 Rsbuild + Vue3 快速实现这个项目的核心内容。其中我最感兴趣的是答题判断,PK 匹配机制等实现不在这篇文章中呈现。
答题判断
用户是通过手写符号进行答题的数据的,因此我们首先需要实现一个手写板。
手写板
这个其实就是我们常见的签名板的场景,没有特殊场景的话参照主流组件库的实现方式编写一个即可。
-
获取画布上下文,通过 getContext 获取
-
触摸事件处理
2.1. touchstart:用户触摸屏幕时,初始化画布绘制环境与绘制属性,并开始记录触摸路径
const touchStart = (e: TouchEvent) => {
if (!ctx.value) {
return false;
}
ctx.value.beginPath();
ctx.value.lineWidth = props.lineWidth;
ctx.value.strokeStyle = props.penColor;
canvasRect = useRect(canvasRef);
emit('start', e);
};
2.2. touchmove: 用户移动手指时,实时获取触摸位置并绘制响应的线条,同时阻止默认行为确保绘制不被中断。
const touchMove = (event: TouchEvent) => {
if (!ctx.value) {
return false;
}
preventDefault(event);
const touch = event.touches[0];
const mouseX = touch.clientX - (canvasRect?.left || 0);
const mouseY = touch.clientY - (canvasRect?.top || 0);
ctx.value.lineCap = 'round';
ctx.value.lineJoin = 'round';
ctx.value.lineTo(mouseX, mouseY);
ctx.value.stroke();
emit('signing', event);
};
2.3. touchEnd: 用户抬起手指,停止绘制。
3. 提交画布内容
当画布不为空时,将内容导出为图像,并暴露出 submit 方法。
const isCanvasEmpty = (canvas: HTMLCanvasElement) => {
const empty = document.createElement('canvas');
empty.width = canvas.width;
empty.height = canvas.height;
if (props.backgroundColor) {
const emptyCtx = empty.getContext('2d');
setCanvasBgColor(emptyCtx);
}
return canvas.toDataURL() === empty.toDataURL();
};
const submit = () => {
const canvas = canvasRef.value;
if (!canvas) {
return;
}
const isEmpty = isCanvasEmpty(canvas);
const image: string = isEmpty
? ''
: (
{
jpg: (): string => canvas.toDataURL('image/jpeg', 0.8),
jpeg: (): string => canvas.toDataURL('image/jpeg', 0.8),
}[props.type] as () => string
)?.() || canvas.toDataURL(`image/${props.type}`);
emit('submit', {
image,
canvas,
});
};
defineExpose({
submit
})
- 清空画布
重置画布内容与背景色实现清空。
const clear = () => {
if (ctx.value) {
ctx.value.clearRect(0, 0, canvasWidth, canvasHeight);
ctx.value.closePath();
setCanvasBgColor(ctx.value);
}
emit('clear');
};
基本上完成以上内容,我们就得到一个基础的手写板了,整体代码如下:
<script lang="ts" setup>
import { computed, defineOptions, onMounted, ref, withDefaults } from 'vue'
import { useRect } from '../composables/useRect';
defineOptions({
name: 'Signature'
})
const stopPropagation = (event: Event) => event.stopPropagation();
function preventDefault(event: Event, isStopPropagation?: boolean) {
/* istanbul ignore else */
if (typeof event.cancelable !== 'boolean' || event.cancelable) {
event.preventDefault();
}
if (isStopPropagation) {
stopPropagation(event);
}
}
const props = withDefaults(defineProps<{
lineWidth?: number
type?: string,
penColor?: string,
backgroundColor?: '',
}>(), {
lineWidth: 3,
type: 'png',
penColor: '#000',
})
const emit = defineEmits<{
start: [TouchEvent]
signing: [TouchEvent]
clear: []
end: [TouchEvent]
submit: [{
image: string
canvas: HTMLCanvasElement
}]
}>()
const canvasRef = ref<HTMLCanvasElement>();
const wrapRef = ref<HTMLElement>();
let canvasRect: DOMRect;
let canvasWidth = 0;
let canvasHeight = 0;
const ctx = computed(() => {
if (!canvasRef.value) return null;
return canvasRef.value.getContext('2d');
});
const touchStart = (e: TouchEvent) => {
if (!ctx.value) {
return false;
}
ctx.value.beginPath();
ctx.value.lineWidth = props.lineWidth;
ctx.value.strokeStyle = props.penColor;
canvasRect = useRect(canvasRef);
emit('start', e);
};
const touchMove = (event: TouchEvent) => {
if (!ctx.value) {
return false;
}
preventDefault(event);
const touch = event.touches[0];
const mouseX = touch.clientX - (canvasRect?.left || 0);
const mouseY = touch.clientY - (canvasRect?.top || 0);
ctx.value.lineCap = 'round';
ctx.value.lineJoin = 'round';
ctx.value.lineTo(mouseX, mouseY);
ctx.value.stroke();
emit('signing', event);
};
const touchEnd = (event: TouchEvent) => {
preventDefault(event);
emit('end', event);
};
const setCanvasBgColor = (
ctx: CanvasRenderingContext2D | null | undefined,
) => {
if (ctx && props.backgroundColor) {
ctx.fillStyle = props.backgroundColor;
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
}
};
const initialize = () => {
if (canvasRef.value) {
const canvas = canvasRef.value;
const dpr = window.devicePixelRatio;
canvasWidth = canvas.width = (wrapRef.value?.offsetWidth || 0) * dpr;
canvasHeight = canvas.height = (wrapRef.value?.offsetHeight || 0) * dpr;
ctx.value?.scale(dpr, dpr);
setCanvasBgColor(ctx.value);
}
};
const clear = () => {
if (ctx.value) {
ctx.value.clearRect(0, 0, canvasWidth, canvasHeight);
ctx.value.closePath();
setCanvasBgColor(ctx.value);
}
emit('clear');
};
const isCanvasEmpty = (canvas: HTMLCanvasElement) => {
const empty = document.createElement('canvas');
empty.width = canvas.width;
empty.height = canvas.height;
if (props.backgroundColor) {
const emptyCtx = empty.getContext('2d');
setCanvasBgColor(emptyCtx);
}
return canvas.toDataURL() === empty.toDataURL();
};
const submit = () => {
const canvas = canvasRef.value;
if (!canvas) {
return;
}
const isEmpty = isCanvasEmpty(canvas);
const image: string = isEmpty
? ''
: (
{
jpg: (): string => canvas.toDataURL('image/jpeg', 0.8),
jpeg: (): string => canvas.toDataURL('image/jpeg', 0.8),
}[props.type] as () => string
)?.() || canvas.toDataURL(`image/${props.type}`);
emit('submit', {
image,
canvas,
});
};
defineExpose({
submit,
clear
})
onMounted(initialize)
</script>
<template>
<div class="signature">
<div ref="wrapRef" class="signature__content">
<canvas
ref="canvasRef"
@touchstart="touchStart"
@touchmove="touchMove"
@touchend="touchEnd"
/>
</div>
<div class="signature__bg">
<span class="text">手写板</span>
</div>
</div>
</template>
<style lang="less">
/* 省略样式 */
</style>
生成问题
具备手写板后,我们需要生成题目,根据调研当前的业界方案,数据结构基本如下:
{
"questions": [
{
"answer": "<",
"question": "0a6"
},
{
"answer": ">",
"question": "12a6"
},
]
}
- questions.answer:标准答案
- question:问题,中间的 a 其实只是个占位符
我们简单使用随机数生成问题即可:
export const generateComparisonQuestions = (count: number) => {
const questions: {
question: string;
answer: string;
}[] = [];
for (let i = 0; i < count; i++) {
// 生成两个 0 到 20 之间的随机数
const num1 = Math.floor(Math.random() * 21);
const num2 = Math.floor(Math.random() * 21);
// 生成问题字符串
const question = `${num1}a${num2}`;
// 根据大小关系生成答案
let answer;
if (num1 > num2) {
answer = '>';
} else if (num1 < num2) {
answer = '<';
} else {
i--; // 重新生成问题
continue;
}
questions.push({ question, answer });
}
return questions;
};
判题
我们只考虑比大小的话,其实只需要考虑用户输入的是 > 还是 < 即可,其他非法内容我们都可以认为是错误的答案。这部分内容实现方案分为两种:
- 根据 > < 符号特征判断用户输入路径
- OCR 识别 canvas 画布图片内容
方案1 其实不太靠谱,但是在这种快速迭代的游戏活动中,真的有公司是这样在线上用的。后面我会讲为什么不靠谱。
- 首先该方案会在画布的 touchstart 到 touchend 事件触发期间,收集所有的用户触摸位置的 clientX 和 clientY。
const touchInfo: Array<{
x: number
y: number
}> = []
const startSign = (e: TouchEvent) => {
touchInfo.push({
x: e.touches[0].clientX,
y: e.touches[0].clientY
})
}
const signing = (e: TouchEvent) => {
touchInfo.push({
x: e.touches[0].clientX,
y: e.touches[0].clientY
})
}
- touchend 时判断收集的位置坐标是否超过三个,不超过则认为是非法输入。然后寻找距离第一个点的 x 最近的一个点,我们称他为 closest。
const endSign = () => {
const closest = touchInfo.reduce((function(pre, current) {
return Math.abs(pre.x - touchInfo[0].x) < Math.abs(current.x - touchInfo[0].x) ? current : pre
}
), {
x: i[0].x,
y: i[0].y
});
}
- 最后判断 closest 的 x 与第一个点的 x 关系,如果 closest.x > touchInfo[0].x ,则认为用户输入是 > ,否则认为是 <
为什么这个方法不靠谱呢?其实看了上面的逻辑你应该也明白一二了,首先这种方案下只要用户的笔画是从左往右,或者从右往左即可完成答题,不需要写出 > < ;其次,存在有用户输入 “>” 号是两个笔划完成的,这种情况下会存在误判。因此相比起来,OCR 是更靠谱的方案,当然上面这种方案开发成本极低,前期快节奏开发也能理解。
由于项目原因,OCR 我选择使用开源的 tesseract.js进行图像中的字符识别。主要步骤如下:
- 获取图像数据
- 创建 OCR worker,配置参数
- 执行识别结果,终止 worker
- 记录 OCR 耗时
const onSubmit = async (data: any) => {
console.time('submit');
image.value = data.image;
const worker = await createWorker('eng');
await worker.setParameters({
tessedit_pageseg_mode: PSM.SINGLE_CHAR,
tessedit_char_whitelist: '><',
});
const ret = await worker.recognize(image.value);
console.log('识别的文字:',ret.data.text);
console.timeEnd('submit');
await worker.terminate();
};
使用流程还是很简单的,但是可能是模型原因也可能是我的使用原因,整体耗时基本在 800ms - 2000ms,而且识别率不是特别理想。(我也不懂机器学习,有懂的大哥可以评论解答~)
线性动画
答题的逻辑在完成以上逻辑后基本完成了,但是我在调研期间发现了个有趣的内容,部分App 在 OCR 识别用户输入后会有一个类似手写批改答案的动画(以下是我实现的效果):
这种效果让我联想到 SVG 线条动画,具体实施步骤如下:
- 去 Figma 随手画一个作为素材
- 导出为 SVG
- 可以作为 Vue 组件添加到项目,或者静态资源引入,我使用Vue 组件。
<template>
<svg width="333" height="258" viewBox="0 0 333 258" fill="none" xmlns="http://www.w3.org/2000/svg">
<path class="path1" d="M5 144C5 174.548 4.26579 206.734 13 236.333C16.9925 249.864 21.4473 257.351 37.1111 251.333C49.2736 246.661 60.7523 239.304 71.4444 232C119.788 198.976 164.516 160.02 208.833 121.889C244.853 90.8969 280.723 58.8293 312.833 23.7222C318.497 17.5297 325.4 11.2004 329 4" stroke="#58EB8B" stroke-width="8" stroke-linecap="round"/>
</svg>
</template>
<script setup lang="ts">
import { defineOptions } from 'vue'
defineOptions({
name: 'Success'
})
</script>
- 添加样式
@keyframes grow {
0% {
stroke-dashoffset: 1px;
stroke-dasharray: 0 1000px;
opacity: 0;
}
10% {
opacity: 1;
}
to {
stroke-dasharray: 1000px 0;
}
}
.path1 {
stroke-dashoffset: 1px;
stroke-dasharray: 1000px 0;
animation: grow 0.5s ease forwards;
transform-origin: center;
animation-delay: 0s;
}
大功告成!最后添加一些界面的样式即可。在实现的过程中我们没使用其他动画库,但是也获得了不错的效果。
总结
通过本文的拆解与实现,我们展示了如何利用前端技术实现一个简易版的口算 PK ,涵盖了从题目生成、手写板构建、答题判断、到 OCR 识别和手写动画等关键环节。尽管有些部分如手写符号判断的 OCR 的性能还可以进一步优化,但整体功能已经基本完成。最后,通过 SVG 实现的线性动画增强了用户体验。
如果有更多时间或需求,在识别准确性以及功能完善性上仍有不少空间可以提升。
相关代码已开源:github.com/ChuHoMan/qu…
结语
如果你对本文的内容感兴趣,或者想了解更多关于前端开发,欢迎关注我的公众号 Label 也是小猪呀。
相关资料
- Animated SVG Logo antfu.me/posts/anima…
- Signature 组件实现 github.com/youzan/vant…