1. 程式人生 > >HTML5 坦克大戰遊戲的製作思路

HTML5 坦克大戰遊戲的製作思路

程式碼
DEMO

不管寫的過程中覺得有多便祕,寫完了回過頭再去看這個遊戲其實並不算多麼的複雜,一些基本的問題處理好就行——這也是這篇文章所想要說明的東西。因此這篇部落格只能算是記錄了一下寫一個遊戲過程中的一些思路,如果有同學也想要自己寫一個遊戲並不知道如何開始的話,我推薦下面兩個內容:

鍵盤事件觸發問題:

如果需要玩家通過按鍵操控坦克進行運動,很多人第一個想到的應該就是把相應的運動函式繫結到相應按鍵的onkeydown事件之上。

一般來說這麼寫有一個問題,那就是為了防止諸如像老人鬆手慢導至鍵盤事件多次觸發這種情況,只有當你按下按鍵到一定的時間以後事件才會連續進行觸發。

這個問題反應到遊戲上就是你的坦克總是要在你按下按鍵後過一段時間才會開始連續運動,非常影響遊戲體驗。

這個問題的解決方法很簡單:

let keyInfo = {};     //按鍵是否被按下的資訊
let aKey = [72 , 74 , 87 , 83 , 65 , 68 , 38 , 40 , 37 , 39 , 17];      //這裡面的數字是wasdhj等按鍵的鍵值

for (let i = 0; i < aKey.length; i++) {
    keyInfo[aKey[i]] = {
        pressed : false
    }
}
  • 將按鍵的鍵值作為屬性名,將按鍵狀態儲存到keyInfo物件中,初始值都為false,表明按鍵未按下。
  • 在按下鍵盤上相應的按鍵的時候,通過事件委託直接捕獲到按下按鍵的keyCode也就是鍵值。
  • onkeydown事件觸發以後將keyInfo中對應的屬性設定為true,表明按鍵被按下,在onkeyup事件觸發以後再將keyInfo中對應的屬性設定為false。
  • 最後在遊戲中迴圈檢測keyInfo中對應按鍵的屬性的真假並執行相應的操作就可以了

路徑問題:

在不提坦克與子彈之間的碰撞問題的前提下,路徑問題基本上就是在確定你的坦克跟子彈(子彈的問題其實更復雜一點,後面再詳細討論)在地圖上哪裡能走哪裡不能走,雖然這個問題並不是很複雜,但在我看來這個問題可以說是整個遊戲的核心所在,因為後面很多問題都是圍繞著路勁而來。

要搞清楚路勁問題還是先要有一些準備工作:

1、地圖:

遊戲的主介面大小為416*416畫素,總共由13*13個32*32畫素的區域構成。

遊戲中的坦克,障礙物及獎勵的圖片的大小都是32*32畫素,因此只要使用一個13*13的陣列就能將整個地圖資料給儲存下來了。

障礙物圖片:


障礙物

第一關地圖資料:
let mapData[0] =[   
    //0代表空白,1代表32*32的磚塊,2代表32*16的磚塊,後面類推
    [0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0],
    [0 , 1 , 0 , 1 , 0 , 1 , 0 , 1 , 0 , 1 , 0 , 1 , 0],
    [0 , 1 , 0 , 1 , 0 , 1 , 0 , 1 , 0 , 1 , 0 , 1 , 0],
    [0 , 1 , 0 , 1 , 0 , 1 , 6 , 1 , 0 , 1 , 0 , 1 , 0],
    [0 , 1 , 0 , 1 , 0 , 2 , 0 , 2 , 0 , 1 , 0 , 1 , 0],
    [0 , 2 , 0 , 2 , 0 , 4 , 0 , 4 , 0 , 2 , 0 , 2 , 0],
    [4 , 0 , 4 , 4 , 0 , 2 , 0 , 2 , 0 , 4 , 4 , 0 , 4],
    [7 , 0 , 2 , 2 , 0 , 4 , 0 , 4 , 0 , 2 , 2 , 0 , 7],
    [0 , 4 , 0 , 4 , 0 , 1 , 1 , 1 , 0 , 4 , 0 , 4 , 0],
    [0 , 1 , 0 , 1 , 0 , 1 , 0 , 1 , 0 , 1 , 0 , 1 , 0],
    [0 , 1 , 0 , 1 , 0 , 2 , 0 , 2 , 0 , 1 , 0 , 1 , 0],
    [0 , 1 , 0 , 1 , 0 , 18 , 4 , 17 , 0 , 1 , 0 , 1 , 0],
    [0 , 0 , 0 , 0 , 0 , 3 , 15 , 5 , 0 , 0 , 0 , 0 , 0]
];

有了地圖資料以後,只要迴圈呼叫drawImage方法就能夠將地圖畫出來了:

for (let i = 0; i < 13; i++) {
    for(let j = 0; j < 13; j++){
        //獲取對應的值
        let iData = mapData[0][i][j];
        if (iData) {
            //如果獲取的值不為0,那麼開始繪製地圖
            cxt.drawImage(oImg, 32 * iData, 0, 32, 32, 32*j, 32*i, 32, 32);
        }
    }
}

第一關介面:


第一關

2、子彈的初始座標


這裡寫圖片描述

其他先不說,看這張圖,最大的那個32px的方塊表示的就是一個坦克,左上角頂點為其座標,四個8px的小方塊表示四個方向發射的子彈,座標也是左上角。

很明顯當坦克在發射子彈的時候,子彈的初始座標必須要根據坦克的座標及方向進行調整,不然發射的子彈位置就不對了。

//iDir表示坦克的方向,x和y的初始值也是坦克的座標,這裡需要根據坦克的方向進行調整才是子彈的初始座標
// 1、3
if (iDir%2) {
    y += 12;
    x += 24*(+!(iDir-1));
// 0, 2
} else {
    x += 12;
    y += 24*iDir/2;
}

3、坦克轉換方向後的對齊

大家可以看上面那個第一關的圖片,很容易就能看出來沒有磚塊的黑色路徑其實跟坦克的寬度是差不多的,當然,他們的寬度都是32個畫素。

那麼問題來了,遊戲中玩家的坦克每次迴圈(一次迴圈16毫秒到17毫秒不等)會移動兩個畫素,除了一開始坦克正好對準了位置以外,以後每次轉換方向,玩家根本沒辦法做到每次都是分毫不差的卡到那個32畫素的點上,那麼按照遊戲的一般規定,對不起你前面有障礙物你無法通過。。。

因此,我們需要在坦克每次改變方向之後,都要正好對準這麼一個點,程式碼如下:

// 在坦克轉換方向後重新定位坦克的位置,使坦克當前移動方向的左邊正好能夠整除16,這樣就正好對齊了磚塊的契合處
//iDir表示坦克當前的方向02表上下,13表右左
//x表示坦克當前的橫座標,y表示坦克當前的縱座標
if (iDir % 2) {  
    y = Math.round(y / 16);
} else {
    x = Math.round(x / 16) * 16;
}

如果仔細看了程式碼,可能有人心中就會出現一個疑問,為什麼是要能夠整除16?OK,下面就來回答這個問題。

4、路徑資料

先回過頭來看看上面那張障礙物的圖片,拿灰褐色的磚塊來說,很明顯可以看到磚塊一共有四種尺寸7張圖,最小是16*16px,最大是32*32px。

想要告訴坦克或者子彈哪裡有障礙物能否通過有兩種方式:

  • 一是將每一種狀態的磚塊都儲存下來,這樣磚塊跟鋼筋加起來共十四種狀態,判斷起來過於麻煩,而且子彈打掉磚塊後的判斷也相應增加了變化的情況。

  • 二是將磚塊都分解為16*16的小磚塊,這樣就不需要判斷磚塊的尺寸了,然後用一個26*26的陣列就能夠將整個地圖的路徑情況給記錄下來。

令:其實這裡還有一個方法那就是把障礙物全部分解為16*16的尺寸,這樣地圖資料直接就是路徑資料了。

碰撞檢測問題:

1、具體有哪些碰撞:


遊戲的介面

所謂的碰撞檢測,按照上面這張遊戲截圖來說明的話主要分為兩類:
  • 坦克的碰撞,這裡面又包括了:

    • 坦克與獎勵的碰撞
    • 坦克與坦克的碰撞
    • 坦克與障礙物的碰撞
  • 子彈的碰撞,這裡面又包括了:

    • 子彈與子彈的碰撞
    • 子彈與坦克的碰撞
    • 子彈與障礙物的碰撞

2、坦克與獎勵、子彈與子彈以及坦克與坦克的碰撞:

①、坦克與獎勵以及子彈與子彈的碰撞的檢測程式碼基本上沒啥區別,因此只舉坦克與獎勵的碰撞來說明:

//坦克與獎勵的碰撞檢測
//坦克的x、y座標分別減去獎勵的x、y座標,如果都小於一個坦克的大小32,那麼表明坦克與獎勵已經碰撞
let xVal = Math.abs(tank.x - bonus.x),
    yVal = Math.abs(tank.y - bonus.y);

if (xVal < 32 && yVal < 32) {
    //碰撞了,執行相應的程式碼
}

如上面程式碼所示,他們之間的碰撞檢測主要就是檢查橫縱座標之差的絕對值,如果這兩個值都小於坦克本身的尺寸,那麼表明他們碰到了一起。

子彈同理,不過是檢測是否小於子彈本身的尺寸8就可以了。

②、表面上看坦克與坦克的碰撞檢測似乎與坦克與獎勵、子彈與子彈的碰撞沒什麼不同,實際上還是有區別的,下面用一張圖說明:


這裡寫圖片描述

左邊紅色的NPC坦克正在渲染出生的動畫,右邊動畫播放完成坦克開始運動,如果這裡還是像之前那樣去檢測,很明顯兩個坦克已經碰到了一起,接下里兩個坦克可能就都無法運動了。

那麼坦克之間的碰撞檢測如下:

//同樣是檢查x、y座標的差值的絕對值
let xVal = Math.abs(this.x - tank.x),
let yVal = Math.abs(this.y - tank.y);
//這裡根據方向的不同,檢測的值也不同,這裡的26留下的餘地,如果兩個坦克正好重疊,那麼他們也是可以運動的
//iDir表示坦克當前的方向02表上下,13表右左
if (iDir % 2) {
    //iDir的值為1或者3,也就是坦克的方向是左右
    if (xVal < 32 && xVal > 26 && yVal < 32) {
         //...
    }
} else {
    //iDir的值為0或者2,也就是坦克的方向是上下
    if (yVal < 32 && yVal > 26 && xVal < 32) {
         //....
    }
}

這裡判斷的值之所以為26,拿y座標來舉例,如之前坦克轉向後對齊裡面的y = Math.round(y / 16)所示,在坦克轉向後坦克座標是會四捨五入的,因為移動速度最慢的坦克每個迴圈會移動1px,因此當(y / 16)< n.5的時候,n*16+1px ~ n* 16+7px會被捨棄,最多是6px,這樣檢測值是32-6=26正好能夠讓兩個坦克在重疊後通過轉向可以繼續運動。

當然這裡也會導至一個BUG,那就是某個時候如果我的坦克正好轉向,座標四捨五入後,有可能會導至兩個坦克重疊,所以這裡也需要在坦克轉換方向後的做一個碰撞檢測,如果正好有重疊那就不往那個方向轉。

3、子彈與坦克的碰撞:

子彈與坦克的碰撞又是另外一回事了,之前也講過子彈的座標是根據發射子彈的坦克的座標重新定位過的,因此檢測的判斷條件跟子彈的方向有很大的關係:

let x = bullet.x - oTank.x;
let y = bullet.y - oTank.y;
if (this.iDir % 2) {
    return (this.iDir -1)
    ? (x < 32 && x > 0 && y > -8 && y < 32)
    : (x > -8 && x < 0 && y > -8 && y < 32);
} else {
    return this.iDir
    ? (y > -8 && y < 0 && x > -8 && x < 32)
    : (y < 32 && y > 0 && x > -8 && x < 32);
}

用方向向上的子彈來舉例:


這裡寫圖片描述

上面兩個32*32px的正方形表示坦克,下面那個8*8的正方形表示子彈,坦克與子彈的座標都位於左上角的頂角處。

當坦克的x座標位於橫著的綠色線條中間之時(-8 <= bullet.x - tank.x <= 32),就表示子彈與坦克在橫座標上相碰撞了。

當坦克的y座標位於豎著的綠線區域內時(0 <= bullet.y - tank.y<= 32),表示子彈與坦克在縱座標上相碰撞了。

兩個條件何在一起,就是方向向上的子彈與坦克的碰撞條件:

y < 32 && y > 0 && x > -8 && x < 32

4、坦克與障礙物的碰撞:

坦克與障礙物的碰撞實際上就是去判斷最早的那個26*26的路徑陣列,看坦克當前方向上所對應的兩個陣列所代表的障礙物是否允許坦克通過。

程式碼並沒有什麼難度,唯一需要注意的是當坦克的方向是向上跟向左的時候,需要分別將傳入的y與x座標-1,這是因為你需要判斷的是下一個路徑陣列的值,而不是當前。

5、子彈與障礙物的碰撞:

如果說子彈一次能打掉最少打掉的是16*16大小的障礙物的話,想要處理也非常簡單,根據座標將對應區域給cxt.clearReact掉,再將相應的路徑陣列置0就能夠解決。

可惜問題並不是這麼簡單,為什麼複雜呢?看下圖就明白了:


這裡寫圖片描述

這是坦克在沒有吃掉星星的時候子彈所能打掉的磚塊,通過對比我們能很清晰的看到子彈一次打掉的磚塊是8*32的區域,等於說一個16*16的障礙物,我們得用兩發子彈才能打掉,這就需要對於灰褐色的磚塊進行特殊處理一下了:
let oBrickStatus = {}; //建立一個物件用來儲存被子彈擊中的磚塊的狀態
let iIndex = x/16*26 + y/16;  //因為路徑陣列的key值是使用x/16與y/16計算而來了,那麼我們將這兩個key值處理一下後得到一個新的數值,這個值用來作為記錄被子彈擊中的磚塊的狀態的key值
//如果oBrickStatus中沒有儲存這個磚塊對應的記錄,那麼將[1, 1, 1, 1]賦值給oBrickStatus[iIndex]
//[1, 1, 1, 1]是因為一個16*16的區域正好可以分成4個8*8的區域,因此用這個陣列記錄下當前16*16的區域有哪些8*8的區域不存在(不存在的陣列值為0)
if (!!oBrickStatus[iIndex]) {
    //這個函式用來計運算元彈擊中磚塊後如何進行處理,下面單獨進行介紹
    hitBrick();   
} else{
    oBrickStatus[iIndex] = [1, 1, 1, 1];
    hitBrick();
}

經過上面那段程式碼,我們只需要去計算oBrickStatus[iIndex]的值,就將這個磚塊的狀態給儲存了下來,如果以後子彈再打中了這個磚塊,那麼就拿出oBrickStatus[iIndex]的值來檢查就可以了。


這裡寫圖片描述

如圖就表示了一個16*16的格子中四個陣列項的分佈情況,左邊表示陣列的索引,右邊表示磚塊是否被打掉(1表示存在,0表示否),一開始四個值都是1。

我們拿子彈方向向上來舉例:

子彈向上的時候首先會檢查索引值為2和3的陣列項,當這兩個值中間有一個不為0的時候,表明子彈與磚塊碰撞了,那麼使用clearReact清空掉相應的區域,並將索引值為2和3的陣列項置0。

如果兩個值都為0,那麼子彈繼續運動,再運動了8個畫素後進入了陣列項0和1表示的區域,此時再檢查這兩塊區域所代表的是否為0,重複之前的操作。

最後在確定一個16*16的磚塊全部被打掉後,直接將路徑陣列中的資料由表示磚塊的1置為0,這樣就實現了子彈對磚塊的擊中後的效果了。

以上就是我對於這個遊戲的一些思考了,這些問題解決後整個遊戲感覺就沒什麼需要注意的地方了,剩下的就是寫了~~~