小游戏《你画我猜》是怎么”调教“的

avatar
研发 @比心APP

前言

《你画我猜》这款游戏想必大家都比较熟悉,不管是在线上还是线下都有玩过,正好接到一个需求,需要用h5实现一个。下载了一些app,玩了一下,发现体验并不是很好,有的同步很慢,有的画笔不流畅。

花了些时间,这些问题一一做了优化,话不多说,和大家分享下技术方案和实现思路。

正文

文章导读

  • 基础功能实现

  • 画笔锯齿毛边

  • 分段传输

  • 防止画笔丢失

  • 画笔流畅性

  • 逻辑梳理图

基础功能实现

有前端基础的小伙伴们应该都知道,H5实现绘画功能,基本原理就是监听触摸事件:触摸开始touchstart,触摸移动touchmove,触摸结束touchend,得到每个触摸点的xy坐标点,使用canvas的API,将这些点连接起来,变成我们的画笔。其中要注意的是:监听事件得到的xy是基于屏幕左上角的坐标点,要想绘画点落在手指下,需要减去canvas元素本身距离屏幕的xy,代码如下:

// html
<canvas ref="drawCanvas"></canvas>

// js
mounted(){
	// 手机端监听canvas元素触摸事件touchstart,touchmove,touchend
	this.$refs.drawCanvas.addEventListener('touchstart', this.canvasTouchstart)
	this.$refs.drawCanvas.addEventListener('touchmove', this.canvasTouchmove)
	this.$refs.drawCanvas.addEventListener('touchend', this.canvasTouchend)
	// canvas宽高
	const { left, top } = this.$refs.drawCanvas.getClientRects()[0]
	this.cl = left
	this.ct = top
	// 初始化canvas
	this.ctx = this.$refs.drawCanvas.getContext('2d')
}
methods:{
	// 开始
	canvasTouchstart(e){     
	  // 兼容pc端事件属性 
      const touch = e.targetTouches ? e.targetTouches[0] : e
      // touch.clientX,touch.clientY,触摸点距离屏幕左上角xy。
	  // this.cl,this.ct,canvas元素距离屏幕的left,top
      const [x, y] = [touch.clientX - this.cl, touch.clientY - this.ct]
      this.ctx.beginPath()
      this.ctx.moveTo(x, y)
	}
	// 移动
	canvasTouchmove(e){
		const touch = e.targetTouches ? e.targetTouches[0] : e
        const [x, y] = [touch.clientX - this.cl, touch.clientY - this.ct]
        this.ctx.lineTo(x, y)
        this.ctx.stroke()
	}
	// 结束
	canvasTouchend(){
		this.ctx.closePath()
	}
}

没错,核心代码就这么点,如果还要适配pc端,那就不是触摸事件了,而是鼠标事件。

this.$refs.drawCanvas.addEventListener('mousedown', this.canvasTouchstart)
document.addEventListener('mousemove', this.canvasTouchmove)
document.addEventListener('mouseup', this.canvasTouchend)

注意:监听鼠标事件时,没有属性e.targetTouches,直接读取e即可,mousemove、mouseup事件要监听document,而非canvas元素,否则鼠标移出canvas后移入,事件监听会出现问题,具体原理这里就不赘述了。

画笔锯齿毛边

基础功能已落地,但是笔画有锯齿和毛边,如何优化呢?

  1. lineJoin和lineCap属性平滑过渡
// 属性定义
ctx.lineJoin 属性设置或返回所创建边角的类型,当两条线交汇时。
ctx.lineCap 属性设置或返回线条末端线帽的样式。
// 初始化是设置属性
this.ctx.lineJoin = 'round'
this.ctx.lineCap = 'round'
  1. canvas增加分辨率

商场的大屏幕我们能看到一个一个像素块,而手机看不到,因为手机分辨率很高,canvas也可以想象成一块屏幕,当分辨率过低时,你看到的就是像素块,也就是锯齿的原因之一,只要增加canvas的实际像素就可以了。

<canvas 
  :width="drawSize.w * 4" 
  :height="drawSize.h * 4" 
  style="`width:${drawSize.w};height:${drawSize.h};`"
  ref="drawCanvas">
</canvas>

也就是说我们实际看到的cancas宽高是:w,h。而他实际的分辨率是:w4,h4

分段传输

  • 数据传输

绘画数据是怎么传输的呢。我们需要在三个监听事件中,记录所有的xy点,打包成不同的绘画帧,发送即可。

canvasTouchstart(){
	// ... other
	this.point=[]
	// 开始帧
	sendPoint({
		type:1,
		point:[{x,y}]
	})
}
canvasTouchmove(){
	// ... other
	// 保存数据
	this.point.push({x,y})
}
canvasTouchend(){
	// ... other
	// 发送移动帧
	sendPoint({
		type:2,
		point:this.point
	})
	// 发送结束帧
	sendPoint({
		type:3
	})
}

猜词方只需要拿到数据后渲染就行了,看上去没什么问题,但是如果绘画方画一笔不松手,连笔画,那这一笔永远也发送不出去,同步不到别人,这也是很多app体验不好的原因之一,怎么解决呢?分段传输。

  • 优化后的分段传输

具体思路:在移动过程中,不管是否画完,每隔一段时间传输一次,这里暂定800ms,(具体为什么是800ms,我是为了方便后续处理流畅性,可视情况而定)结束时再把剩下的传输过去。代码如下:

this.currentTime=new Date()
// 移动中
canvasTouchmove(){
    // ... other
    this.point.push({x,y})
    if(new Date() - this.currentTime >= 800){
        sendPoint({
                type:2,
                point:this.point
        })
        this.point=[]
    }
}

这样传输,无论你一笔画多久,别人都可以尽快的收到画完的数据。

防止画笔丢失

网络是不稳定的,你并不能保证你每一笔一定到达服务器,也不能保证服务器一定能通知到所有人,那怎么给他们上保险呢?

具体思路:

每一笔都携带递增的唯一id。

绘画方:将所有发送帧先保留本地,定时循环发送本地数据,等待收到服务器发送”帧回复“,删除本地该帧。

猜词方:本地id递增,每次收到帧,检测是否是下一帧。如果是,则绘制,如果id小于下一帧,则不处理;如果大于下一帧,则暂存,主动向服务器请求丢失帧(如收到id=2,id=5,处理id=5数据时,先保存到本地,然后主动向服务器请求id=3,id=4)。每次收到帧,循环本地数组,看是否有下一帧可绘制。

代码如下:

// 绘画方
this.id=1
mounted(){
    // 定时发送未反馈的绘画帧
    setInterval(()=>{
        this.drawData.forEach(e=>{
                sendPoint(e)
        })
    },2000)
    // 监听服务端会回复,删除帧
    Ws.on(-321,e=>{
        this.drawData = this.drawData.filter(v => v.id !== e.id)
    })
}
methods:{
    canvasTouchstart(){
        // ... other
        // 发送并保存开始帧
        sendPoint(startdata)
        this.drawData.push(startdata)
        this.id++
    }
    canvasTouchmove(){
        // ... other
        // 发送并保存移动帧
        sendPoint(movedata)
        this.drawData.push(movedata)
        this.id++
    }
    canvasTouchend(){
        // ... other
        // 发送并保存移动帧
        sendPoint(movedata)
        this.drawData.push(movedata)
        // 发送并保存结束帧
        sendPoint(enddata)
        this.drawData.push(enddata)
        this.id++
    }
}



// 猜词方
this.id=1  // 本地id
this.localDrawData=[]  // 本地未绘制数组
mounted(){
    Ws.on(-320,e=>{
        e.forEach(v=>{
            if(this.id+1===v.id){
                // 绘制
                draw()
                this.id=v.id
            }
            if(this.id+1<v.id){
                // 保存本地,并请求
                this.localDrawData.push(v)
                // 请求下一个id,指定长度的数据
                requestLostDraw({
                    lostId:this.id+1,
                    lostLen:v.id-(this.id+1)
                })
            }	
        })
        // 每次收到帧后,循环本地数据,看是否可以绘制
        this.localDrawData.forEach(v=>{
            if(this.id+1===v.id){
                // 绘制
                draw()
                this.id=v.id
            }
        })
    })
}

画笔流畅性(重点)

目前为止,还存在一个体验上的优化点,就是猜词者看到的笔画是一段一段展示的。如图所示:

old.gif

问题来了,为什么会这样呢?

我们的绘画数据是每800ms发送一段,这一段里面不管有多少个点,猜词方都会直接绘制,眼睛看到的就是直接生成了一条线。

那么如何让它变得丝滑呢?

众所周知,我们大部分手机刷新率是60hz(当然也有更高的90hz,120hz),屏幕每秒刷新60次图像。60hz已经足够让我们看到画面是顺滑的了,只要让笔画做到60hz刷新率,我们看到的就是顺滑的。1000ms/60=16.6ms,也就是说,只要将笔画16ms渲染一次就行了。

具体思路:

  1. 数据分小段

为了及时到达,上面已经做过分段处理,800ms一次,我们是不是只要将时间改成16ms就解决了?是,也不是。问题也会随之而来,传输数据过大,服务器顶不住。还有方法,就是收到数据后自己进行拆分,将800ms一段的数据打散成16ms一段,50份数据。代码如下:

    // 打散返回的数据,为小集合
    handlePartData (res) {
      if (res.point) {
        // 处理画笔移动点数据
        if (res.cmdType === 2) {
          const pointNum = res.point.length / this.drawIntervalTime
          // 公共属性
          const attr = {
            cmdType: res.cmdType,
            size: res.size,
            color: res.color,
          }
          // 计算出的小集合大于1,一帧一个点的小集合
          if (pointNum > 1) {
            const num = Math.ceil(pointNum); const pointArr = []
            for (let i = 0, len = res.point.length; i < len; i++) {
              const v = res.point[i]
              if (!(i % num) && i !== 0) {
                this.miniDrawList.push({
                  ...attr,
                  point: [...pointArr],
                })
                pointArr.length = 0
              }
              pointArr.push({ x: v.x, y: v.y })
            }
            if (pointArr.length) {
              this.miniDrawList.push({
                ...attr,
                point: [...pointArr],
              })
            }
          } else {
            // 点集合个数少于一,每一帧一个点
            res.point.forEach(v => {
              this.miniDrawList.push({
                ...attr,
                point: [{ x: v.x, y: v.y }],
              })
            })
          }
        } else {
          // 处理清空,开始,结束的数据
          this.miniDrawList.push(res)
        }
      }
    },
  1. 16ms一笔进行渲染

数据已经拆分好了,按照16ms渲染一次,将所有数据有序渲染就行,setInterval?不,有更好的,requestAnimationFrame,官方解释:告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。代码如下:

mounted(){
    this.animationFrameFun()
}
methods:{
    animationFrameFun () {
      this.drawLocalData()
      this.drawTimer = requestAnimationFrame(this.animationFrameFun)
    },
    drawLocalData () {
      let drawStatus = false
      while (!drawStatus) {
        if (!this.miniDrawList.length) return
        const data = this.miniDrawList[0]
		// 只有移动数据需要有序渲染,其他数据直接执行
        if (data.cmdType === 2) {
          drawStatus = true
        } else {
          this.draw(this.miniDrawList[0])
          this.miniDrawList.shift()
        }
      }
      this.draw(this.miniDrawList[0])
      this.miniDrawList.shift()
    },
}

好了,问题解决完了,逻辑也讲完了,希望对大家有所帮助和启发。

逻辑梳理图

另附笔画处理的逻辑梳理图,帮助理解。

  • 绘画者

  • 猜词者

注意:代码片段均为伪代码,意在表达其思想,如果想要直接使用,还要考虑其复用性,封装性,以及内存泄漏等问题哦~

结尾

好了,分享到这就结束了,感谢到这里,您的观看就是对我的认可。

♥️比心♥️