會作這個 Puzzle Game,仍是應前幾天 lightyears 的一次提議,模仿的是鷹腳網絡首頁左下角那個拼圖小遊戲。那天晚上睡覺的時候在牀上想了一下,大體 get 到了它內部實現的原理,因而就乾脆動手實踐一番,如今也順道寫一篇博客記錄下實現思路和中間遇到的一些問題。 css
Puzzle Game 的遊戲過程爲:用戶上傳圖片後選擇要分割的碎片數量,一顆星表明 2 * 2 = 4 個碎片, 兩顆星表明 3 * 3 = 9 個碎片,以此類推八顆星則是 81 個碎片。經過拖拽碎片進行拼圖,當每一個碎片和其相鄰的碎片間隔都不超過閾值時,則提示拼圖成功。html
傳送門, Go to play?vue
GitHub,喜歡的話就給個 star 鼓勵鼓勵吧😊。git
Puzzle Game 的難點主要是兩個:一是如何對上傳的圖片進行切割成各個碎片,二是如何判斷是否拼圖成功。鷹腳網絡那個拼圖由於是固定的,就只有那一張圖片和四個圖片碎片,因此大能夠事先把圖片分割成 4 塊碎片再寫進代碼裏。但 Puzzle Game 使用的圖片和碎片數量取決於用戶選擇,因此就得另闢蹊徑了。github
這裏我利用到的是精靈圖,根據用戶選擇的碎片數量,生成 N 個<div>
(表明每個碎片),將用戶上傳的圖片設置爲每個<div>
的背景圖片,再使用background-position
把每個碎片都定位到圖片相應的位置,這樣就能夠實現把圖片切割成各個碎片啦。Puzzle Game 仍是挺簡單的,每個碎片都是等大的,並且是 n * n 的碎片數量,相對容易實現不少。不過即便是 n * m 也是同樣的,只要改變一下碎片的寬高和background-position
的定位就能夠了,問題不大。但若是每個碎片都不等大的話就麻煩了,目前沒想到實現思路。api
至於判斷拼圖是否成功,我想到的辦法是:先設置好一個閾值,在每次拖拽完畢後就遍歷每個碎片,判斷它和相鄰碎片(上下左右四個碎片,對於邊界碎片再行判斷)之間的間隔是否不超過這個閾值。若是每個碎片都不超過,則拼圖成功;如有一個碎片和相鄰某一個碎片之間的間隔超過了閾值,則直接結束判斷過程。此處判斷碎片間的距離使用的是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 座標 - 碎片自身寬 / 高的一半bash
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
都沒法相應地及時變化(可見官方文檔)。因此須要等 DOM 更新後才能獲取到$refs
,能夠利用Vue.$nextTick(callback)
實現。
說到這個就有些尷尬了,我以前覺得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 和這篇博客,五一假期就只剩下這半天了啊。我仍是滾回去寫個人數據結構實驗了,揮揮。