拼圖小遊戲

若是您想要綜合使用javascript中canvas、原生拖拽、本地存儲等多種技術完成一個有趣的項目,那麼這篇博文將很是適合您,水平有限,還望感興趣的開發人員給予更多代碼優化建議。

1 簡介和源碼

該項目中的拼圖小遊戲使用javascript原創,相比於網站上相似的功能,它使用到的技術點更先進豐富,功能更強大,還包含程序開發中更多先進的思想理念,從該項目中您將能學到:javascript

  • FileReader、Image對象的配合canvas對圖片進行壓縮,切割的技巧。
  • 學習小遊戲開發中最經常使用的碰撞檢測、狀態監控、刷新保持狀態的處理方法。
  • 深刻了解拖拽交換元素的細節,學習到動態元素綁定事件、回調函數的處理方式。

項目源碼-githubjava

下面是遊戲界面的示例圖:
圖片描述node

2 實現思路

根據遊戲界面圖咱們能夠將完成這麼一個小遊戲分爲如下幾步來實現:git

  • 1.拖拽圖片到指定區域,使用FileReader對象讀取到圖片的base64內容,而後添加到Image對象中
  • 2.當Image對象加載完成後,使用canvas對圖片進行等比縮放,而後取到縮略圖的base64內容,添加到另一個縮略圖Image對象中,並將該縮略圖base64的內容保存到本地存儲(localStorage)中
  • 3.當縮略圖Image對象加載完成後,再次使用canvas對縮略圖進行切割,該遊戲中將縮略圖切割成3*4一共12等份,使用本地存儲保存每份切割縮略圖base64內容,將縮略圖順序打亂,使用img標籤顯示在web頁面上
  • 4.當縮略圖切片都添加到web界面上之後,爲每一份縮略圖切片添加註冊拖拽事件,使得縮略圖切片能夠相互交換,在這個過程中,添加對縮略圖切片順序狀態的監控,一旦完成拼圖,就直接展現完整的縮略圖,完成遊戲

從以上對小遊戲製做過程的分析來看,第4步是程序功能實現的重點和難點,在以上的每一個步驟中都有不少小細節須要注意和探討,下面我就詳細分析一下每一個步驟的實現細節,說的很差的地方,歡迎你們留言指正。github

3 開發細節詳解

3.1 圖片內容讀取和加載

在遊戲開發第1步中,咱們將圖片拖拽到指定區域後,程序是怎樣獲得圖片內容信息的呢?fileReader對象又是怎樣將圖片信息轉化爲base64字符串內容的?Image對象拿到圖片的base64內容以後,又是怎樣初始化加載的?帶着這些疑問,咱們來研究一下實現項目中實現了第一步的關鍵代碼。web

var droptarget = document.getElementById("droptarget"),
            output = document.getElementById("ul1"),
            thumbImg = document.getElementById("thumbimg");
            
 //此處省略相關代碼........
           
function handleEvent(event) {
                var info = "",
                    reader = new FileReader(),
                    files, i, len;

                EventUtil.preventDefault(event);
                localStorage.clear();

                if (event.type == "drop") {
                    files = event.dataTransfer.files;
                    len = files.length;
                    if (!/image/.test(files[0].type)) {
                        alert('請上傳圖片類型的文件');
                    }
                    if (len > 1) {
                        alert('上傳圖片數量不能大於1');
                    }

                    var canvas = document.createElement('canvas');
                    var context = canvas.getContext('2d');
                    var img = new Image(),          //原圖
                        thumbimg = new Image();     //等比縮放後的縮略圖

                    reader.readAsDataURL(files[0]);
                    reader.onload = function (e) {
                        img.src = e.target.result;
                    }

                    //圖片對象加載完畢後,對圖片進行等比縮放處理。縮放後最大寬度爲三百像素
                    img.onload = function () {
                        var targetWidth, targetHeight;
                        targetWidth = this.width > 300 ? 300 : this.width;
                        targetHeight = targetWidth / this.width * this.height;
                        canvas.width = targetWidth;
                        canvas.height = targetHeight;
                        context.clearRect(0, 0, targetWidth, targetHeight);
                        context.drawImage(img, 0, 0, targetWidth, targetHeight);
                        var tmpSrc = canvas.toDataURL("image/jpeg");
                        //在本地存儲完整的縮略圖源
                        localStorage.setItem('FullImage', tmpSrc);
                        thumbimg.src = tmpSrc;
                    }
                    
        //此處省略相關代碼......
        
         EventUtil.addHandler(droptarget, "dragenter", handleEvent);
         EventUtil.addHandler(droptarget, "dragover", handleEvent);
         EventUtil.addHandler(droptarget, "drop", handleEvent);            
}

這段代碼的思路就是首先得到拖拽區域目標對象droptarget,爲droptarget註冊拖拽監聽事件。代碼中用到的EventUtil是我封裝的一個對元素添加事件、事件對象的兼容處理等經常使用功能的簡單對象,下面是其添加註冊事件的簡單簡單代碼,其中還有不少其餘的封裝,讀者可自行查閱,功能比較簡單。json

var EventUtil = {

    addHandler: function(element, type, handler){
        if (element.addEventListener){
            element.addEventListener(type, handler, false);
        } else if (element.attachEvent){
            element.attachEvent("on" + type, handler);
        } else {
            element["on" + type] = handler;
        }
    },
    
    //此處省略代......
 }

當用戶將圖片文件拖放到區域目標對象droptarget時,droptarget的事件對象經過event.dataTransfer.files獲取到文件信息,對文件進行過濾(限制只能爲圖片內容,而且最多隻能有一張圖片)。拿到文件內容之後,使用FileReader對象reader讀取文件內容,使用其readAsDataURL方法讀取到圖片的base64內容,賦值給Image對象img的src屬性,就能夠等到img對象初始化加載完畢,使canvas對img進行下一步的處理了。這裏有一個重點的地方須要說明:必定要等img加載完成後,再使用canvas進行下一步的處理,否則可能會出現圖片損壞的狀況。緣由是:當img的src屬性讀取圖片文件的base64內容時,可能尚未將內容加載到內存中時,canvas就開始處理圖片(此時的圖片是不完整的)。因此咱們能夠看到canvas對圖片的處理是放在img.onload方法中進行的,程序後邊還會有這種狀況,以後就再也不贅述了。canvas

3.2 圖片等比縮放和本地存儲

在第一步中咱們完成了對拖拽文件的內容讀取,並將其成功加載到了Image對象img中。接下來咱們使用canvas對圖片進行等比縮放,對圖片進行等比縮放,咱們採起的策略是限制圖片的最大寬度爲300像素,咱們再來看一下這部分代碼吧:數組

img.onload = function () {
                        var targetWidth, targetHeight;
                        targetWidth = this.width > 300 ? 300 : this.width;
                        targetHeight = targetWidth / this.width * this.height;
                        canvas.width = targetWidth;
                        canvas.height = targetHeight;
                        context.clearRect(0, 0, targetWidth, targetHeight);
                        context.drawImage(img, 0, 0, targetWidth, targetHeight);
                        var tmpSrc = canvas.toDataURL("image/jpeg");
                        //在本地存儲完整的縮略圖源
                        localStorage.setItem('FullImage', tmpSrc);
                        thumbimg.src = tmpSrc;
                    }

肯定了縮放後的寬度targetWidth和高度targetHeight以後,咱們使用canvas的drawImage方法對圖像進行壓縮,在這以前咱們最好先使用畫布的clearRect對畫布進行一次清理。對圖片等比縮放之後,使用canvas的toDataURL方法,獲取到縮放圖的base64內容,賦給新的縮放圖Image對象thumbimg的src屬性,待縮放圖加載完畢,進行下一步的切割處理。縮放圖的base64內容使用localStorage存儲,鍵名爲"FullImage"。瀏覽器的本地存儲localStorage是硬存儲,在瀏覽器刷新以後內容不會丟失,這樣咱們就能夠在遊戲過程當中保持數據狀態,這點稍後再詳細講解,咱們須要知道的是localStorage是有大小限制的,最大爲5M。這也是爲何咱們先對圖片進行壓縮,減小存儲數據大小,保存縮放圖base64內容的緣由。關於開發過程當中存儲哪些內容,下一小節會配有圖例詳細說明。瀏覽器

3.3 縮略圖切割

生成縮略圖以後要作的工做就是對縮略圖進行切割了,一樣的也是使用canvas的drawImage方法,並且相應的處理必須放在縮略圖加載完成以後(即thumbimg.onload)進行處理,緣由前面咱們已經說過。下面咱們再來詳細分析一下源代碼吧:

thumbimg.onload = function () {
                        //每個切片的寬高[切割成3*4格式]
                        var sliceWidth, sliceHeight, sliceBase64, n = 0, outputElement = '',
                            sliceWidth = this.width / 3,
                            sliceHeight = this.height / 4,
                            sliceElements = [];

                        canvas.width = sliceWidth;
                        canvas.height = sliceHeight;

                        for (var j = 0; j < 4; j++) {
                            for (var i = 0; i < 3; i++) {
                                context.clearRect(0, 0, sliceWidth, sliceHeight);
                                context.drawImage(thumbimg, sliceWidth * i, sliceHeight * j, sliceWidth, sliceHeight, 0, 0, sliceWidth, sliceHeight);
                                sliceBase64 = canvas.toDataURL("image/jpeg");
                                localStorage.setItem('slice' + n, sliceBase64);
                                //爲了防止圖片三像素問題發生,請爲圖片屬性添加 display:block
                                newElement = "<li name=\"" + n + "\" style=\"margin:3px;\"><img src=\"" + sliceBase64 + "\" style=\"display:block;\"></li>";
                                //根據隨機數打亂圖片順序
                                (Math.random() > 0.5) ? sliceElements.push(newElement) : sliceElements.unshift(newElement);
                                n++;
                            }
                        }

                        //拼接元素
                        for (var k = 0, len = sliceElements.length; k < len; k++) {
                            outputElement += sliceElements[k];
                        }

                        localStorage.setItem('imageWidth', this.width + 18);
                        localStorage.setItem('imageHeight', this.height + 18);
                        output.style.width = this.width + 18 + 'px';
                        output.style.height = this.height + 18 + 'px';
                        (output.innerHTML = outputElement) && beginGamesInit();

                        droptarget.remove();
                    }

上面的代碼對於你們來講不難理解,就是將縮略圖分割成12個切片,這裏我給你們解釋一下幾個容易困惑的地方:

  • 1.爲何咱們再切割圖片的時候,代碼以下,先從列開始循環?
for (var j = 0; j < 4; j++) {
    for (var i = 0; i < 3; i++) {
        //此處省略邏輯代碼
    }
  }

這個問題你們仔細想想就明白了,咱們將圖片進行切割的時候,要記錄下來每個圖片切片的原有順序。在程序中咱們使用 n 來表示圖片切片的原有順序,並且這個n記錄在了每個圖片切片的元素的name屬性中。在後續的遊戲過程當中咱們可使用元素的getAttribute('name')方法取出 n 的值,來判斷圖片切片是否都被拖動到了正確的位置,以此來判斷遊戲是否結束,如今講起這個問題可能還會有些迷惑,咱們後邊還會再詳細探討,我給出一張圖幫助你們理解圖片切片位置序號信息n:

圖片描述

序號n從零開始是爲了和javascript中的getElementsByTagName()選擇的子元素座標保持一致。

  • 2 咱們第3步實現的目的不只是將縮略圖切割成小切片,還要將這些圖片切片打亂順序,代碼程序中這一點是怎樣實現的?
    閱讀代碼程序咱們知道,咱們每生成一個切片,就會構造一個元素節點: newElement = "<li name=\"" + n + "\" style=\"margin:3px;\"><img src=\"" + sliceBase64 + "\" style=\"display:block;\"></li>"; 。咱們在是在外部先聲明瞭一個放新節點的數組sliceElements,咱們每生成一個新的元素節點,就會把它放到sliceElements數組中,可是咱們向sliceElements頭部仍是尾部添加這個新節點則是隨機的,代碼是這樣的:
(Math.random() > 0.5) ? sliceElements.push(newElement) : sliceElements.unshift(newElement);

咱們知道Math.random()生成一個[0, 1)之間的數,因此再canvas將縮略圖裁切成切片之後,根據這些切片生成的web節點順序是打亂的。打亂順序之後從新組裝節點:

//拼接元素
for (var k = 0, len = sliceElements.length; k < len; k++) {
    outputElement += sliceElements[k];
}

而後再將節點添加到web頁面中,也就天然而然出現了圖片切片被打亂的樣子了。

  • 3.咱們根據縮略圖切片生成的DOM節點是動態添加的元素,怎樣給這樣動態元素綁定事件呢?咱們的項目中爲每一個縮略圖切片DOM節點綁定的事件是「拖動交換」,和其餘節點都有關係,咱們要保證全部的節點都加載後再對事件進行綁定,咱們又是怎樣作到的呢?

下面的一行代碼,雖然簡單,可是用的很是巧妙:

(output.innerHTML = outputElement) && beginGamesInit();

有開發經驗的同窗都知道 && 和 || 是短路運算符,代碼中的含義是:只有當切片元素節點都添加到
WEB頁面以後,纔會初始化爲這些節點綁定事件。

3.4 本地信息存儲

代碼中屢次用到了本地存儲,下面咱們來詳細解釋一下本遊戲開發過程當中都有哪些信息須要存儲,爲何要存儲?下面是我給出的須要存儲的信息圖示例(從瀏覽器控制檯獲取):

圖片描述

瀏覽器本地存儲localStorage使用key:value形式存儲,從圖中咱們看到咱們本次存儲的內容有:

  • FullImage:圖片縮略圖base64編碼。
  • imageWidth:拖拽區域圖片的寬度。
  • imageHeight:拖拽區域圖片的高度。
  • slice*:每個縮略圖切片的base64內容。
  • nodePos:保存的是當前縮略圖的位置座標信息。

保存FullImage縮略圖的信息是當遊戲結束後顯示源縮略圖時,根據FullImage中的內容展現圖片。而imageWidth,imageHeight,slice*,nodePos是爲了防止瀏覽器刷新致使數據丟失所作的存儲,當刷新頁面的時候,瀏覽器會根據本地存儲的數據加載沒有完成的遊戲內容。其中nodePos是在爲縮略圖切片發生拖動時存入本地存儲的,而且它隨着切片位置的變化而變化,也就是它追蹤着遊戲的狀態,咱們在接下來的代碼功能展現中會再次說到它。

3.5 拖拽事件註冊和監控

接下來咱們要作的事纔是遊戲中最重要的部分,仍是先來分析一下代碼,首先是事件註冊前的初始化工做:

//遊戲開始初始化
function beginGamesInit() {
    aLi = output.getElementsByTagName("li");
    for (var i = 0; i < aLi.length; i++) {
        var t = aLi[i].offsetTop;
        var l = aLi[i].offsetLeft;
        aLi[i].style.top = t + "px";
        aLi[i].style.left = l + "px";
        aPos[i] = {left: l, top: t};
        aLi[i].index = i;
        //將位置信息記錄下來
        nodePos.push(aLi[i].getAttribute('name'));
    }
    for (var i = 0; i < aLi.length; i++) {
        aLi[i].style.position = "absolute";
        aLi[i].style.margin = 0;
        setDrag(aLi[i]);
    }
}

能夠看到這部分初始化綁定事件代碼所作的事情是:記錄每個圖片切片對象的位置座標相關信息記錄到對象屬性中,併爲每個對象都註冊拖拽事件,對象的集合由aLi數組統一管理。這裏值得一提的是圖片切片的位置信息index記錄的是切片如今所處的位置,而咱們前邊所提到的圖片切片name屬性所保存的信息n則是圖片切片本來應該所處的位置,在遊戲尚未結束以前,它們不必定相等。待全部的圖片切片name屬性所保存的值和其屬性index都相等時,遊戲纔算結束(由於用戶已經正確完成了圖片的拼接),下面的代碼就是用來判斷遊戲狀態是否結束的,看起來更直觀一些:

//判斷遊戲是否結束
function gameIsEnd() {
    for (var i = 0, len = aLi.length; i < len; i++) {
        if (aLi[i].getAttribute('name') != aLi[i].index) {
            return false;
        }
    }

    //後續處理代碼省略......
}

下面咱們仍是詳細說一說拖拽交換代碼相關邏輯吧,拖拽交換的代碼以下圖所示:

//拖拽
function setDrag(obj) {
    obj.onmouseover = function () {
        obj.style.cursor = "move";
        console.log(obj.index);
    }

    obj.onmousedown = function (event) {
        var scrollTop = document.documentElement.scrollTop || document.body.scrollTop;
        var scrollLeft = document.documentElement.scrollLeft || document.body.scrollLeft;
        obj.style.zIndex = minZindex++;
        //當鼠標按下時計算鼠標與拖拽對象的距離
        disX = event.clientX + scrollLeft - obj.offsetLeft;
        disY = event.clientY + scrollTop - obj.offsetTop;
        document.onmousemove = function (event) {
            //當鼠標拖動時計算div的位置
            var l = event.clientX - disX + scrollLeft;
            var t = event.clientY - disY + scrollTop;
            obj.style.left = l + "px";
            obj.style.top = t + "px";

            for (var i = 0; i < aLi.length; i++) {
                aLi[i].className = "";
            }
            var oNear = findMin(obj);
            if (oNear) {
                oNear.className = "active";
            }
        }

        document.onmouseup = function () {
            document.onmousemove = null;       //當鼠標彈起時移出移動事件
            document.onmouseup = null;         //移出up事件,清空內存
            //檢測是否普碰上,在交換位置
            var oNear = findMin(obj);
            if (oNear) {
                oNear.className = "";
                oNear.style.zIndex = minZindex++;
                obj.style.zIndex = minZindex++;

                startMove(oNear, aPos[obj.index]);
                startMove(obj, aPos[oNear.index], function () {
                    gameIsEnd();
                });

                //交換index
                var t = oNear.index;
                oNear.index = obj.index;
                obj.index = t;

                //交換本次存儲中的位置信息
                var tmp = nodePos[oNear.index];
                nodePos[oNear.index] = nodePos[obj.index];
                nodePos[obj.index] = tmp;
                localStorage.setItem('nodePos', nodePos);
            } else {
                startMove(obj, aPos[obj.index]);
            }
        }
        clearInterval(obj.timer);

        return false;//低版本出現禁止符號
    }
}

這段代碼所實現的功能是這樣子的:拖動一個圖片切片,當它與其它的圖片切片有碰撞重疊的時候,就和與其左上角距離最近的一個圖片切片交換位置,並交換其位置信息index,更新本地存儲信息中的nodePos。移動完成以後判斷遊戲是否結束,若沒有,則期待下一次用戶的拖拽交換。
下面我來解釋一下這段代碼中比較難理解的幾個點:

  • 1.圖片切片在被拖動的過程當中是怎樣判斷是否和其它圖片切片發生碰撞的?這就是典型的碰撞檢測問題。
    程序中實現碰撞檢測的代碼是這樣的:
//碰撞檢測
function colTest(obj1, obj2) {
    var t1 = obj1.offsetTop;
    var r1 = obj1.offsetWidth + obj1.offsetLeft;
    var b1 = obj1.offsetHeight + obj1.offsetTop;
    var l1 = obj1.offsetLeft;

    var t2 = obj2.offsetTop;
    var r2 = obj2.offsetWidth + obj2.offsetLeft;
    var b2 = obj2.offsetHeight + obj2.offsetTop;
    var l2 = obj2.offsetLeft;

    `if (t1 > b2 || r1 < l2 || b1 < t2 || l1 > r2)` {
        return false;
    } else {
        return true;
    }
}

這段代碼看似信息量不多,其實也很好理解,判斷兩個圖片切片是否發生碰撞,只要將它們沒有發生碰撞的情形排除掉就能夠了。這有點相似與邏輯中的非是即否,兩個切片又確實只可能存在兩種狀況:碰撞、不碰撞。圖中的這段代碼是判斷不碰撞的狀況:if (t1 > b2 || r1 < l2 || b1 < t2 || l1 > r2),返回false, else 返回true。

2.碰撞檢測完成了以後,圖片切片之間又是怎樣尋找左上角定點距離最近的元素呢?

代碼是這個樣子的:

//勾股定理求距離(左上角的距離)
function getDis(obj1, obj2) {
    var a = obj1.offsetLeft - obj2.offsetLeft;
    var b = obj1.offsetTop - obj2.offsetTop;
    return Math.sqrt(Math.pow(a, 2) + Math.pow(b, 2));
}

//找到距離最近的
function findMin(obj) {
    var minDis = 999999999;
    var minIndex = -1;
    for (var i = 0; i < aLi.length; i++) {
        if (obj == aLi[i]) continue;
        if (colTest(obj, aLi[i])) {
            var dis = getDis(obj, aLi[i]);
            if (dis < minDis) {
                minDis = dis;
                minIndex = i;
            }
        }
    }
    if (minIndex == -1) {
        return null;
    } else {
        return aLi[minIndex];
    }
}

由於都是矩形區塊,因此計算左上角的距離使用勾股定理,這點相信你們都能明白。查找距離最近的元素原理也很簡單,就是遍歷全部已經碰撞的元素,而後比較根據勾股定理計算出來的最小值,返回元素就能夠了。代碼中也是使用了比較通用的方法,先聲明一個很大的值最爲最小值,當有碰撞元素比其小時,再將更小的值最爲最小值,遍歷完成後,返回最小值的元素就能夠了。

  • 3.圖片區塊每次交換以後,是怎樣監控判斷遊戲是否已經結束的呢?

答案是回調函數,圖片切片交換函數經過回調函數來判斷遊戲是否已經結束,遊戲是否結束的判斷函數前面咱們已經說過。圖片切片交換函數就是經過添加gameIsEnd做爲回調函數,這樣在每次圖片切片移動交換完成以後,就判斷一下游戲是否結束。圖片切片的交換函數仍是比較複雜的,有興趣的同窗能夠研究一下,下面是其實現代碼,你們重點理解其中添加了回調函數監控遊戲是否結束就行了。

//經過class獲取元素
function getClass(cls){
    var ret = [];
    var els = document.getElementsByTagName("*");
    for (var i = 0; i < els.length; i++){
        //判斷els[i]中是否存在cls這個className;.indexOf("cls")判斷cls存在的下標,若是下標>=0則存在;
        if(els[i].className === cls || els[i].className.indexOf("cls")>=0 || els[i].className.indexOf(" cls")>=0 || els[i].className.indexOf(" cls ")>0){
            ret.push(els[i]);
        }
    }
    return ret;
}
function getStyle(obj,attr){//解決JS兼容問題獲取正確的屬性值
    return obj.currentStyle?obj.currentStyle[attr]:getComputedStyle(obj,false)[attr];
}

function gameEnd() {
    alert('遊戲結束!');
}

function startMove(obj,json,fun){
    clearInterval(obj.timer);
    obj.timer = setInterval(function(){
        var isStop = true;
        for(var attr in json){
            var iCur = 0;
            //判斷運動的是否是透明度值
            if(attr=="opacity"){
                iCur = parseInt(parseFloat(getStyle(obj,attr))*100);
            }else{
                iCur = parseInt(getStyle(obj,attr));
            }
            var ispeed = (json[attr]-iCur)/8;
            //運動速度若是大於0則向下取整,若是小於0想上取整;
            ispeed = ispeed>0?Math.ceil(ispeed):Math.floor(ispeed);
            //判斷全部運動是否所有完成
            if(iCur!=json[attr]){
                isStop = false;
            }
            //運動開始
            if(attr=="opacity"){
                obj.style.filter = "alpha:(opacity:"+(json[attr]+ispeed)+")";
                obj.style.opacity = (json[attr]+ispeed)/100;
            }else{
                obj.style[attr] = iCur+ispeed+"px";
            }
        }
        //判斷是否所有完成
        if(isStop){
            clearInterval(obj.timer);
            if(fun){
                fun();
            }
        }
    },30);
}

4 補充和總結

4.1 遊戲中值得完善的功能

我認爲該遊戲中值得優化的地方有兩個:

  • 1.爲拼圖小遊戲添加縮略圖,由於縮略圖有利於爲玩遊戲的用戶提供思路。咱們又在瀏覽器本地存儲中保存了縮略圖的base64內容,因此實現起來也很容易。
  • 2.緩存有的時候也讓人很痛苦,就好比說在遊戲中有些用戶就想要從新開始,而咱們的小遊戲只有在遊戲完成以後才清空緩存,刷新頁面,遊戲纔可以從新開始。這給用戶的體驗很很差,咱們能夠加一個重置遊戲按鈕,清空緩存並優化遊戲結束後的一些邏輯。

這些功能感興趣的小夥伴能夠嘗試一下。

4.2 總結

雖然花了週末幾乎一天的時間寫了幾百行代碼才實現了一個功能不是很強大的小遊戲,可是在這個過程當中查閱了不少資料,總算把本身喜歡作的一件事情給完成了,仍是很開心的。寫這篇博客的目的是爲了和更多有相同興趣愛好的小夥伴分享一下本身的看法,筆者水平有限,但願你們對代碼有好的建議或者有更好的思路留言相告。感謝你們!

相關文章
相關標籤/搜索