速度挑戰 - 2小時完成HTML5拼圖小游戲


概述

我用lufylegend.js開發了第一個HTML5小游戲——拼圖游戲,還寫了篇博文來炫耀一下:HTML5小游戲《智力大拼圖》發布,挑戰你的思維風暴。

詳細


初學lufylegend.js之日,我用lufylegend.js開發了第一個HTML5小游戲——拼圖游戲,還寫了篇博文來炫耀一下:HTML5小游戲《智力大拼圖》發布,挑戰你的思維風暴。不過當時初學游戲開發,經驗淺薄,所以沒有好好專研游戲里的算法和代碼的缺陷,導致游戲出現了很多bug,甚至拼圖打亂后很可能無法復原。最近經常有朋友問起這個游戲,希望我能把代碼里的bug改一下方便初學者學習,順便我也打算測試一下自己寫這種小游戲的速度,所以就抽出了一些時間將這個游戲從頭到尾重新寫了一遍,計算了一下用時,從准備、修改素材到最后完成游戲,一共用了大約2h的時間。

這是我的游戲記錄,歡迎各位挑戰:

 

接下來就來講講如何開發完成這款游戲的。(按“編年體”)

一、准備工作

 

准備lufylegend游戲引擎,大家可以去官方網站下載:

lufylegend.com/lufylegend

引擎文檔地址:

lufylegend.com/lufylegend/api

可以說,如果沒有強大的lufylegend引擎,這種html5小游戲用原生canvas制作,少說要一天呢。

二、程序實現

0~30min

准備素材(10min) + 修改素材(20min)。由於在下實在手殘,不善於P圖,修改圖片用了大約20min,囧……

30~50min

開發開始界面。游戲不能沒有開始界面所以我們首先實現這部分代碼。在此之前是index.html里的代碼,代碼如下:

<!DOCTYPE html>
<html>
<head>
    <title>Puzzle</title>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no">
    <script type="text/javascript" src="./lib/lufylegend-1.10.1.simple.min.js"></script>
    <script type="text/javascript" src="./js/Main.js"></script>
</head>
<body style="margin: 0px; font-size: 0px; background: #F2F2F2;">
    <div id="mygame"></div>
</body>
</html>

主要是引入一些js文件,不多說。然后准備一個Main.js文件,在這個文件里添加初始化界面和加載資源的代碼:

/** 初始化游戲 */
LInit(60, "mygame", 390, 580, main);

var imgBmpd;
/** 游戲層 */
var stageLayer, gameLayer, overLayer;
/** 拼圖塊列表 */
var blockList;
/** 是否游戲結束 */
var isGameOver;
/** 用時 */
var startTime, time, timeTxt;
/** 步數 */
var steps, stepsTxt;

function main () {
    /** 全屏設置 */
    if (LGlobal.mobile) {
        LGlobal.stageScale = LStageScaleMode.SHOW_ALL;
    }
    LGlobal.screen(LGlobal.FULL_SCREEN);

    /** 添加加載提示 */
    var loadingHint = new LTextField();
    loadingHint.text = "資源加載中……";
    loadingHint.size = 20;
    loadingHint.x = (LGlobal.width - loadingHint.getWidth()) / 2;
    loadingHint.y = (LGlobal.height - loadingHint.getHeight()) / 2;
    addChild(loadingHint);

    /** 加載圖片 */
    LLoadManage.load(
        [
            {path : "./js/Block.js"},
            {name : "img", path : "./images/img.jpg"}
        ],
        null,
        function (result) {
            /** 移除加載提示 */
            loadingHint.remove();

            /** 保存位圖數據,方便后續使用 */
            imgBmpd = new LBitmapData(result["img"]);

            gameInit();
        }
    );
}

function gameInit (e) {
    /** 初始化舞台層 */
    stageLayer = new LSprite();
    stageLayer.graphics.drawRect(0, "", [0, 0, LGlobal.width, LGlobal.height], true, "#EFEFEF");
    addChild(stageLayer);

    /** 初始化游戲層 */
    gameLayer = new LSprite();
    stageLayer.addChild(gameLayer);

    /** 初始化最上層 */
    overLayer = new LSprite();
    stageLayer.addChild(overLayer);

    /** 添加開始界面 */
    addBeginningUI();
}

以上代碼有詳細注釋,大家可以對照引擎文檔和注釋進行閱讀。有些全局變量會在以后的代碼中使用,大家可以先忽略。接下來是addBeginningUI函數里的代碼,用於實現開始界面:

function addBeginningUI () {
    var beginningLayer = new LSprite();
    beginningLayer.graphics.drawRect(0, "", [0, 0, LGlobal.width, LGlobal.height], true, "#EDEDED");
    stageLayer.addChild(beginningLayer);

    /** 游戲標題 */
    var title = new LTextField();
    title.text = "拼圖游戲";
    title.size = 50;
    title.weight = "bold";
    title.x = (LGlobal.width - title.getWidth()) / 2;
    title.y = 160;
    title.color = "#FFFFFF";
    title.lineWidth = 5;
    title.lineColor = "#000000";
    title.stroke = true;
    beginningLayer.addChild(title);

    /** 開始游戲提示 */
    var hint = new LTextField();
    hint.text = "- 點擊屏幕開始游戲 -";
    hint.size = 25;
    hint.x = (LGlobal.width - hint.getWidth()) / 2;
    hint.y = 370;
    beginningLayer.addChild(hint);

    /** 開始游戲 */
    beginningLayer.addEventListener(LMouseEvent.MOUSE_UP, function () {
        beginningLayer.remove();

        startGame();
    });
}

到此,運行代碼,得到我們的開始界面:

看到這個畫面,其實我自己都想吐槽一下實在是太“朴素”了,囧……

不過我這次圖個制作速度,所以還望各位看官海量。

50~90min

這40分鍾的時間,是最關鍵時期,期間我們要完成整個游戲的主體部分。首先,我們需要用代碼來實現以下過程:

初始化游戲界面數據(如游戲時間、所用步數)和顯示一些UI部件(如圖樣)
|
-> 獲取隨機的拼圖塊位置
|
-> 顯示打亂后的拼圖塊

我們將這些步驟做成一個個的函數方便我們統一調用:

function startGame () {
    isGameOver = false;

    /** 初始化時間和步數 */
    startTime = (new Date()).getTime();
    time = 0;
    steps = 0;
    /** 初始化拼圖塊列表 */
    initBlockList();
    /** 打亂拼圖 */
    getRandomBlockList();
    /** 顯示拼圖 */
    showBlock();
    /** 顯示縮略圖 */
    showThumbnail();
    /** 顯示時間 */
    addTimeTxt();
    /** 顯示步數 */
    addStepsTxt();

    stageLayer.addEventListener(LEvent.ENTER_FRAME, onFrame);
}

函數一開始,我們把isGameOver變量設定為false代表游戲未結束,在后期的代碼里,我們會看到這個變量的作用。接着我們初始化了用於表示時間和步數的time和steps這兩個全局變量,另外初始化變量startTime的值用於后面計算游戲時間。

接下來,我們就要開始初始化拼圖塊了。見initBlockList里的代碼:

function initBlockList () {
    blockList = new Array();

    for (var i = 0; i < 9; i++) {
        /** 根據序號計算拼圖塊圖片顯示位置 */
        var y = (i / 3) >>> 0, x = i % 3;

        blockList.push(new Block(i, x, y));
    }
}

這里我們使用了一個Block類,這個類用於顯示拼圖塊和儲存拼圖塊的數據,並提供了一些方法來操控拼圖塊,下面是其構造器的代碼:

function Block (index, x, y) {
    LExtends(this, LSprite, []);

    var bmpd = imgBmpd.clone();
    bmpd.setProperties(x * 130, y * 130, 130, 130);
    this.bmp = new LBitmap(bmpd);
    this.addChild(this.bmp);

    var border = new LShape();
    border.graphics.drawRect(3, "#CCCCCC", [0, 0, 130, 130]);
    this.addChild(border);

    this.index = index;

    this.addEventListener(LMouseEvent.MOUSE_UP, this.onClick);
}

Block類繼承自LSprite,屬於一個顯示對象,所以我們在這個類中添加了一個位圖對象用於顯示拼圖塊對應的圖片。除此之外,我們還為拼圖塊添加了一個邊框,在顯示時用於隔開周圍的拼圖塊。Block類有一個index屬性,代表拼圖塊在拼圖塊列表blockList中的正確位置。最后,我們為此類添加了一個鼠標按下事件,用於處理鼠標按下后移動圖塊操作。

 

接下來我們還要介紹這個類的一個方法setLocation:

Block.prototype.setLocation = function (x, y) {
    this.locationX = x;
    this.locationY = y;

    this.x = x * 130;
    this.y = y * 130;
};

這個方法用於設置拼圖塊對象的顯示位置以及保存拼圖塊的“數組位置”。什么是“數組位置”呢?各位看官可以通過下面的圖片加以了解:

可以看到,“數組位置”就類似於二維數組中的元素下標。儲存這個位置的作用在於可以很方便地從blockList中獲取到附近的其他拼圖塊。這個方法在我們顯示拼圖時有調用到,在顯示拼圖之前,我們得先打亂拼圖,見如下代碼:

function getRandomBlockList () {
    /** 隨機打亂拼圖 */
    blockList.sort(function () {
        return 0.5 - Math.random();
    });

    /** 計算逆序和 */
    var reverseAmount = 0;

    for (var i = 0, l = blockList.length; i < l; i++) {
        var currentBlock = blockList[i];

        for (var j = i + 1; j < l; j++) {
            var comparedBlock = blockList[j];

            if (comparedBlock.index < currentBlock.index) {
                reverseAmount++;
            }
        }
    }

    /** 檢測打亂后是否可還原 */
    if (reverseAmount % 2 != 0) {
        /** 不合格,重新打亂 */
        getRandomBlockList();
    }
}

打亂拼圖部分直接用數組的sort方法進行隨機打亂:

blockList.sort(function () {
    return 0.5 - Math.random();
});

其實打亂算法有很多種,我這里采用最粗暴的方法,也就是隨機打亂。這種算法簡單是簡單,壞在可能出現無法復原的現象。針對這個問題,就有配套的檢測打亂后是否可還原的算法,具體的算法理論我借用lufy大神的評論:

此類游戲能否還原關鍵是看它打亂后的逆序次數之和是否為偶數 
假設你打亂后的數組中的每一個小圖塊為obj0,obj1,obj2,…它們打亂之前的序號分別為obj0.num,obj1.num… 
接下來循環數組,如果前面元素的序號比此元素后某個元素的序號大,如obj0.num > obj1.num或者obj2.num > obj4.num就表示一個逆序 
當全部的逆序之和為奇數時表示不可還原,重新打亂即可,打亂后重新檢測,直到逆序之和為偶數為止

 

舉個例子,如果有一個數組為[3, 4, 2, 1],那么里面3 2, 3 1, 2 4, 4 1, 2 1是逆序的,所以逆序數是5。

 

上面我給出的getRandomBlockList里的代碼就是在實現打亂算法和檢測是否可還原算法。

 

還有一種打亂方式,大家可以嘗試嘗試:和復原拼圖一樣,將空白塊一步一步地與周圍的拼圖隨機交換順序。這個打亂算法較上一種而言,不會出現無法復原的現象,而且可以根據打亂的步數設定游戲難度。

 

在完成打亂拼圖塊后,如期而至的是顯示拼圖塊:

function showBlock() {
    for (var i = 0, l = blockList.length; i < l; i++) {
        var b = blockList[i];

        /** 根據序號計算拼圖塊位置 */
        var y = (i / 3) >>> 0, x = i % 3;

        b.setLocation(x, y);

        gameLayer.addChild(b);
    }
}

顯示了拼圖塊后,我們要做的就是添加操作拼圖塊的功能。於是需要拓展Block類,為其添加事件監聽器onClick方法:

Block.prototype.onClick = function (e) {
    var self = e.currentTarget;

    if (isGameOver) {
        return;
    }

    var checkList = new Array();

    /** 判斷右側是否有方塊 */
    if (self.locationX > 0) {
        checkList.push(Block.getBlock(self.locationX - 1, self.locationY));
    }

    /** 判斷左側是否有方塊 */
    if (self.locationX < 2) {
        checkList.push(Block.getBlock(self.locationX + 1, self.locationY));
    }

    /** 判斷上方是否有方塊 */
    if (self.locationY > 0) {
        checkList.push(Block.getBlock(self.locationX, self.locationY - 1));
    }

    /** 判斷下方是否有方塊 */
    if (self.locationY < 2) {
        checkList.push(Block.getBlock(self.locationX, self.locationY + 1));
    }

    for (var i = 0, l = checkList.length; i < l; i++) {
        var checkO = checkList[i];

        /** 判斷是否是空白拼圖塊 */
        if (checkO.index == 8) {
            steps++;
            updateStepsTxt();

            Block.exchangePosition(self, checkO);

            break;
        }
    }
};

 

首先,我們在這里看到了isGameOver全局變量的作用,即在游戲結束后,阻斷點擊拼圖塊后的操作。

 

在點擊了拼圖塊后,我們先獲取該拼圖塊周圍的拼圖塊,並將它們裝入checkList,再遍歷checkList,當判斷到周圍有空白拼圖塊后,即周圍有index屬性等於8的拼圖塊后,先更新操作步數,然后將這兩個拼圖塊交換位置。具體交換拼圖塊位置的方法詳見如下代碼:

Block.exchangePosition = function (b1, b2) {
    var b1x = b1.locationX, b1y = b1.locationY,
        b2x = b2.locationX, b2y = b2.locationY,
        b1Index = b1y * 3 + b1x,
        b2Index = b2y * 3 + b2x;

    /** 在地圖塊數組中交換兩者位置 */
    blockList.splice(b1Index, 1, b2);
    blockList.splice(b2Index, 1, b1);

    /** 交換兩者顯示位置 */
    b1.setLocation(b2x, b2y);
    b2.setLocation(b1x, b1y);

    /** 判斷游戲是否結束 */
    Block.isGameOver();
};

還有就是Block.getBlock靜態方法,用於獲取給定的“數組位置”下的拼圖塊:

Block.getBlock = function (x, y) {
    return blockList[y * 3 + x];
};

在Block.exchangePosition中,我們通過Block.isGameOver判斷玩家是否已將拼圖復原:

Block.isGameOver = function () {
    var reductionAmount = 0, l = blockList.length;

    /** 計算還原度 */
    for (var i = 0; i < l; i++) {
        var b = blockList[i];

        if (b.index == i) {
            reductionAmount++;
        }
    }

    /** 計算是否完全還原 */
    if (reductionAmount == l) {
        /** 游戲結束 */
        gameOver();
    }   
};

到這里,我們就實現了打亂和操作拼圖塊部分。

90~120min

最后30min用於細枝末節上的處理,如顯示拼圖縮略圖、顯示&更新時間和步數,以及添加游戲結束畫面,這些就交給如下冗長而簡單的代碼來完成吧:

function showThumbnail() {
    var thumbnail = new LBitmap(imgBmpd);
    thumbnail.scaleX = 130 / imgBmpd.width;
    thumbnail.scaleY = 130 / imgBmpd.height;
    thumbnail.x = (LGlobal.width - 100) /2;
    thumbnail.y = 410;
    overLayer.addChild(thumbnail);
}

function addTimeTxt () {
    timeTxt = new LTextField();
    timeTxt.stroke = true;
    timeTxt.lineWidth = 3;
    timeTxt.lineColor = "#54D9EF";
    timeTxt.color = "#FFFFFF";
    timeTxt.size = 18;
    timeTxt.x = 20;
    timeTxt.y = 450;
    overLayer.addChild(timeTxt);

    updateTimeTxt();
}

function updateTimeTxt () {
    timeTxt.text = "時間:" + getTimeTxt(time);
}

function getTimeTxt () {
    var d = new Date(time);

    return d.getMinutes() + " : " + d.getSeconds();
};

function addStepsTxt () {
    stepsTxt = new LTextField();
    stepsTxt.stroke = true;
    stepsTxt.lineWidth = 3;
    stepsTxt.lineColor = "#54D9EF";
    stepsTxt.color = "#FFFFFF";
    stepsTxt.size = 18;
    stepsTxt.y = 450;
    overLayer.addChild(stepsTxt);

    updateStepsTxt();
}

function updateStepsTxt () {
    stepsTxt.text = "步數:" + steps;

    stepsTxt.x = LGlobal.width - stepsTxt.getWidth() - 20;
}

function onFrame () {
    if (isGameOver) {
        return;
    }

    /** 獲取當前時間 */
    var currentTime = (new Date()).getTime();

    /** 計算使用的時間並更新時間顯示 */
    time = currentTime - startTime;
    updateTimeTxt();
}

function gameOver () {
    isGameOver = true;

    var resultLayer = new LSprite();
    resultLayer.filters = [new LDropShadowFilter()];
    resultLayer.graphics.drawRoundRect(3, "#BBBBBB", [0, 0, 350, 350, 5], true,"#DDDDDD");
    resultLayer.x = (LGlobal.width - resultLayer.getWidth()) / 2;
    resultLayer.y = LGlobal.height / 2;
    resultLayer.alpha = 0;
    overLayer.addChild(resultLayer);

    var title = new LTextField();
    title.text = "游戲通關"
    title.weight = "bold";
    title.stroke = true;
    title.lineWidth = 3;
    title.lineColor = "#555555";
    title.size = 30;
    title.color = "#FFFFFF";
    title.x = (resultLayer.getWidth() - title.getWidth()) / 2;
    title.y = 30;
    resultLayer.addChild(title);

    var usedTimeTxt = new LTextField();
    usedTimeTxt.text = "游戲用時:" + getTimeTxt(time);
    usedTimeTxt.size = 20;
    usedTimeTxt.stroke = true;
    usedTimeTxt.lineWidth = 2;
    usedTimeTxt.lineColor = "#555555";
    usedTimeTxt.color = "#FFFFFF";
    usedTimeTxt.x = (resultLayer.getWidth() - usedTimeTxt.getWidth()) / 2;
    usedTimeTxt.y = 130;
    resultLayer.addChild(usedTimeTxt);

    var usedStepsTxt = new LTextField();
    usedStepsTxt.text = "所用步數:" + steps;
    usedStepsTxt.size = 20;
    usedStepsTxt.stroke = true;
    usedStepsTxt.lineWidth = 2;
    usedStepsTxt.lineColor = "#555555";
    usedStepsTxt.color = "#FFFFFF";
    usedStepsTxt.x = usedTimeTxt.x;
    usedStepsTxt.y = 180;
    resultLayer.addChild(usedStepsTxt);

    var hintTxt = new LTextField();
    hintTxt.text = "- 點擊屏幕重新開始 -";
    hintTxt.size = 23;
    hintTxt.stroke = true;
    hintTxt.lineWidth = 2;
    hintTxt.lineColor = "#888888";
    hintTxt.color = "#FFFFFF";
    hintTxt.x = (resultLayer.getWidth() - hintTxt.getWidth()) / 2;
    hintTxt.y = 260;
    resultLayer.addChild(hintTxt);

    LTweenLite.to(resultLayer, 0.5, {
        alpha : 0.7,
        y : (LGlobal.height - resultLayer.getHeight()) / 2,
        onComplete : function () {
            /** 點擊界面重新開始游戲 */
            stageLayer.addEventListener(LMouseEvent.MOUSE_UP, function () {
                gameLayer.removeAllChild();
                overLayer.removeAllChild();

                stageLayer.removeAllEventListener();

                startGame();
            });
        }
    });
}

Ok,2h下來,整個游戲就搞定咯~不得不表揚一下lufylegend這個游戲引擎,實在是可以大幅提升開發效率。

三、文件截圖以及運行效果

1、文件截圖

blob.png

2、雙擊index.html即可運行

3、運行時的截圖

blob.png

四、其他補充

這篇博文在最初寫成的時候,我沒有對逆序算法進行深入研究,再加上我的測試不仔細,我沒有發現算法的錯誤之處。因此,在博文發布后,不少讀者發現游戲無解現象並將此問題反饋給了我,經過網友熱心幫助,我才找到了問題所在,並更正了算法。在此對這些熱心的網友表示真心的感謝,也為我學習不深入,以及誤導了不少讀者而感到十分內疚自責。

 

注:本文著作權歸作者,由demo大師發表,拒絕轉載,轉載需要作者授權


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM