前言
会做这个 Puzzle Game,还是应前几天 lightyears 的一次提议,模仿的是鹰脚网络首页左下角那个拼图小游戏。那天晚上睡觉的时候在床上想了一下,大致 get 到了它内部实现的原理,于是就干脆动手实践一番,现在也顺道写一篇博客记录下实现思路和中间遇到的一些问题。
实现
介绍
Puzzle Game 的游戏过程为:用户上传图片后选择要分割的碎片数量,一颗星代表 2 * 2 = 4 个碎片, 两颗星代表 3 * 3 = 9 个碎片,以此类推八颗星则是 81 个碎片。通过拖拽碎片进行拼图,当每个碎片和其相邻的碎片间隔都不超过阈值时,则提示拼图成功。
传送门, Go to play?
GitHub,喜欢的话就给个 star 鼓励鼓励吧?。
首先要解决的两个难点
Puzzle Game 的难点主要是两个:一是如何对上传的图片进行切割成各个碎片,二是如何判断是否拼图成功。鹰脚网络那个拼图因为是固定的,就只有那一张图片和四个图片碎片,所以大可以事先把图片分割成 4 块碎片再写进代码里。但 Puzzle Game 使用的图片和碎片数量取决于用户选择,所以就得另辟蹊径了。
这里我利用到的是精灵图,根据用户选择的碎片数量,生成 N 个<div>
(代表每一个碎片),将用户上传的图片设置为每一个<div>
的背景图片,再使用background-position
把每一个碎片都定位到图片相应的位置,这样就可以实现把图片切割成各个碎片啦。Puzzle Game 还是挺简单的,每一个碎片都是等大的,而且是 n * n 的碎片数量,相对容易实现很多。不过即使是 n * m 也是一样的,只要改变一下碎片的宽高和background-position
的定位就可以了,问题不大。但如果每一个碎片都不等大的话就麻烦了,目前没想到实现思路。
至于判断拼图是否成功,我想到的办法是:先设置好一个阈值,在每次拖拽完毕后就遍历每一个碎片,判断它和相邻碎片(上下左右四个碎片,对于边界碎片再行判断)之间的间隔是否不超过这个阈值。如果每一个碎片都不超过,则拼图成功;若有一个碎片和相邻某一个碎片之间的间隔超过了阈值,则直接结束判断过程。此处判断碎片间的距离使用的是element.getBoundingClientRect()
,四个属性值left
、right
、top
、bottom
统一是相对于浏览器视口来计算的。(具体代码实现可以看这里)
遇到的一些问题
拖拽后计算碎片的位置
拖拽碎片进行移动使用到的 api 有三个:mousedown
、mousemove
、mouseup
(移动端则是对应的touchstart
、touchmove
、touchend
。不过 Puzzle Game 没有适应移动端,因为我觉得通过拖拽来实现拼图而且碎片数量还是不确定的,这需要大屏幕才方便操作,移动端屏幕太小不适合)。在mousemove
的过程中实时更新碎片的位置,计算方法有两个:
- 鼠标拖拽过程中移动的距离 + 碎片原先离父元素的左 / 上边距
let x = e.clientX - startX + px;
let y = e.clientY - startY + py;
复制代码
其中e.clientX
是鼠标松开时鼠标的 x 坐标,startX 是一开始鼠标按下时的 x 坐标,px 是碎片原先离父元素的左边距,由targetEle.offsetLeft
得到。 2. 鼠标松开时的 x / y 坐标 - 碎片自身宽 / 高的一半
let x = e.clientX - targetEle.clientWidth / 2;
let y = e.clientY - targetEle.clientHeight / 2;
复制代码
这两个计算方法本身没有问题,但和后面的targetEle.style.left = `${x}px`; targetEle.style.top = `${y}px`;
合用的时候就产生了一个参照物的问题。方法一使用到的targetEle.offsetLeft
和targetEle.offsetTop
是相对于它的offsetParent
而言的,也就是它第一个设置有定位的父元素,如果它的父级元素都没有定位则为 body。而碎片设置的left
和top
属性也是相对于它第一个设有定位的父级元素而言的,所以不管碎片的父元素如何定位(默认也好,绝对 / 相对 / 固定定位也罢),得出的 x 和 y 值以及left
和top
都是相对于其父元素而言的,是统一的。
但使用方法二算出来的 x 和 y 是相对于浏览器视口左上角而言的,和left
和top
的参照物可能不一样,所以使用方法二有时候就会出现拖拽图片但图片却偏离到右下角去了的情况。因此要准确地使用方法二是有个前提条件的,就是碎片的父元素必须得是相对于浏览器左上角定位的。这和父元素是否设置了定位无关,因为如果父元素没有设置定位,那么父元素自然是相对于浏览器左上角来定位的(即使父元素前面已经有其他的元素了)。而如果父元素设置了定位,只要父元素位于浏览器左上角从而让碎片还是相对于浏览器左上角定位那也是可以的,比如父元素设置了定位但left
和top
为 0 并且前面没有其他的元素,或者父元素前面的元素都脱离了文档流。只有满足这几个条件,才能正确使用方法二,否则拖拽后图片的位置会出现异常,例如下面图三所示。
接下来我们先用代码和结果图进一步验证,看客可以戳这里查看具体代码自行验证,这里就只放效果图不放代码啦。
-
父元素没有设置定位
-
父元素设置了定位(
relative
/absolute
/fixed
定位都行),但父元素位于浏览器左上角的位置(left
和top
为 0 且前面没有元素,或是前面的元素都脱离了文档流)。 -
父元素设置了定位,但设置了非零的
left
和top
,或是前面已有在文档流中占位的元素。
考虑到使用方法二会有一些限制条件,所以还是推荐使用方法一的好,比较健壮适应性也好。如果使用方法二的话,则要注意上述的这些坑点,免得跳坑里了。(咦,如果不跳一次坑哪来的这篇博客??)
拖拽速度过快
在监听事件的时候,我们通常都是把监听函数绑定到相应的目标元素上的,很少把监听函数绑定到document
或body
上(监听页面滚动和利用事件委托等除外)。所以当我把mousedown
、mousemove
、mouseup
这三个监听函数绑定到碎片上时,mousedown
没什么问题,问题就出在了mousemove
和mouseup
上。如果点击碎片后拖拽的速度过大,就会造成鼠标移动过快而碎片来不及响应移动。要知道,mousemove
触发频率是很高的,相应的监听函数也会被高频率调用。这很容易造成碎片的移动速度跟不上鼠标的移动速度,结果就是鼠标移出了碎片后,即使鼠标没有松开但因为监听函数失去了目标所以碎片也不会再跟着移动。而因为鼠标松开后也没能触发相应的监听函数,所以此时标记鼠标移动是否开始的变量还是为true
,又造成了当鼠标移动到碎片上时即使没有按住鼠标碎片也还是可以跟着鼠标移动。具体代码和效果可以戳这里。
我使用的解决办法很简单,直接把mousemove
和mouseup
这两个监听函数绑定到document
或者body
上就行了。这样即使鼠标移动速度过快离开了碎片,也还是能触发到相应的监听函数让碎片也跟着移动。这里还有一个注意点,计算得到 x 和 y 的值要修改碎片的left
和top
属性时,不能使用e.target
来获取碎片自身了。因为鼠标移动过快离开碎片后此时的e.target
便成document
了,所以**需要事先使用一个变量来缓存e.target
**才行。
接下来再说说把mousemove
和mouseup
这两个监听函数绑定到document
和body
的区别。document
包括了整个浏览器视图,而body
只包括了网页正文(脱离文档流的元素还不算在body
的宽高上)。所以如果body
没有达到整个浏览器视图的大小,那把监听函数绑定到body
上和原先绑定在碎片上没有啥区别。如果body
的宽高已经和浏览器视图一样大小了,比如手动设置为100vw
和100vh
,此时绑定到document
或body
上的区别在于两者对边界情况的处理不同。绑定到document
上时即使鼠标移到了页面正文外(比如浏览器的工具栏和桌面的任务栏)碎片也还是能跟着继续移动,而绑定到body
上如果鼠标移出了页面正文碎片就不会跟着移动了,只有鼠标再移回页面正文碎片的位置才会继续跟着响应。(若绑定到window
或者html
上则跟绑定到document
上是一样的。)
看效果图会更直观点,下图一是mousemove
和mouseup
监听函数绑定在document
上,下图二是绑定在body
上(当然代码中可不能直接写body
,得写成document.body
才行)。
不知道看客有没有想到一个问题,把mousemove
和mouseup
的监听函数绑定到document
上会不会造成事件触发频率过高的问题?毕竟监听对象从原来的几个碎片扩大成了整个document
啊。不可避免地事件触发频率会高很多,但没办法,我想不出其他的解决方案啊,只能去尽量避免过多触发到监听函数了。比如把mousemove
和mouseup
的监听写在mousedown
的监听函数里,这和前面使用一个变量标记鼠标移动是否开始差不多。但主要的是在mouseup
的监听函数里把document
上的mousemove
和mouseup
监听事件清除掉。比如document.onmousemove = null; document.onmouseup = null;
。这样只有在点击碎片的时候才会监听事件并且点击完毕后就马上清除掉了,触发监听函数的次数少了很多。
上传的图片过大
如果用户上传的图片过大,甚至超过了浏览器视窗大小,那必然得对图片进行压缩后,否则图片都铺满了整个浏览器视窗还怎么进行拼图。这里我采用的方法也很简单,上传完图片后对图片的大小进行判断,如果超过了限定值(我设置的是浏览器宽度的一半)则将图片的宽度缩小为这个限定值,再根据原始的宽高比例和压缩后的图片宽度计算出压缩后的图片高度就行了。这里我还遇到两个小问题。
-
我使用
imageEle.naturalWidth
和imageEle.naturalHeight
来获取图片大小,但如果直接读取的话你会发现得到的图片宽高都是 0。这是因为 imageEle 的 src 属性是依赖于用户上传的图片来动态赋值的,需要等图片加载完成后才能获取到它的宽高,图片还没有加载完成就去获取得到的自然就是 0 了。解决方法是使用setTimeout(callback, 0)
来异步获取图片宽高,如果想要更及时获取到图片宽高也可以开一个setInterval
隔一段时间就去判断获取到的图片宽高是否非 0,是的话则代表图片已经加载完成就可以结束setInterval
了。我本来以为使用Vue.$nextTick
在下次 DOM 更新时再获取图片宽高也是可以的,但发现不行,估计是下次 DOM 更新了图片也可能没有加载完毕吧,这点不是很清楚。 -
另一个问题是,缩小图片尺寸后页面会有一瞬间图片从大变小的过程。解决方法也很简单,**事先给图片设置一个
max-width
**就行了,这个max-width
也就是前面提到的限定值,这样图片就不会有一个大小的瞬间变化了。不过这得在 js 代码里去设置,在 css 里没法获取到浏览器宽高。
$refs 的获取
因为要分割成的碎片数量取决于用户的选择,所以所有的碎片都是动态加载的。而$refs
不是响应式的,只能在组件渲染完成后才能获取到,所有动态加载的模板更新时$refs
都无法相应地及时变化(可见官方文档)。所以需要等 DOM 更新后才能获取到$refs
,可以利用Vue.$nextTick(callback)
实现。
对 background-position 理解出错
说到这个就有些尴尬了,我之前以为background-position
的两个属性值 x 和 y 代表的是在这张图片上定位到(x, y)这个坐标的位置上再显示背景图片,结果导致使用background-position
定位到的碎片显示的图片位置错位。又因为我老是以为是我中间哪里出问题了所以一直没有想到是我自己对background-position
理解出错上去(Orz),最后单独起了个 demo 实验才发现问题所在。原来background-position
的两个属性值 x 和 y 代表的是将这张图片向右移动和向下移动指定的距离后再显示背景图片。和我之前理解的恰恰相反,而且因为我之前没有设置background-repeat: no-repeat
所以如果只有四个碎片的话是不会表现出什么问题的。知道了background-position
的真正意思后要解决就很容易了,只要把 x 和 y 都变成负值就可以啦。
后记
Puzzle Game 写起来还是挺简单的,主要是一时兴起练练手吧。中午去食堂的时候才想起来我在写一个Web Project的时候应该边写边记录的才对啊。像这个 Puzzle Game 就是边写代码的过程中边三言两语记录下遇到的问题和解决方案,这样事后才可以总结成一篇博客,记录自己遇到的坑点和盲点。不然就跟之前写的cloud music一样,没有边写代码边记录遇到的问题,所以等写完代码后都差不多忘记中间遇到的很多问题了,写成的博客也就很空淡。好吧,吸收经验,下次就要记得正确的学习姿势了!
花了三天的时间写完 Puzzle Game 和这篇博客,五一假期就只剩下这半天了啊。我还是滚回去写我的数据结构实验了,挥挥。