- 原文地址:How To Build Minesweeper With JavaScript
- 原文做者:Mitchum
- 譯文出自:掘金翻譯計劃
- 本文永久連接:github.com/xitu/gold-m…
- 譯者:ZavierTang
- 校對者:Stevens1995、githubmnume
在個人上一篇文章中,我向你們介紹了一款使用 JavaScript 編寫的三連棋遊戲,在那以前我也編寫了一款匹配遊戲。本週,我決定增長一些複雜性。大家將學習如何用 JavaScript 編寫掃雷遊戲。我使用了 jQuery,這是一個有助於與 HTML 交互的 JavaScript 庫。當你看到一個函數的調用帶有一個前導的美圓($
)符號時,這就是 jQuery 的操做。若是你想了解更多關於 jQuery 的內容,閱讀官方文檔是最佳的選擇。javascript
點擊試玩掃雷遊戲!這款遊戲推薦在臺式電腦上體驗,由於這樣更便於操做。css
下面是建立這個遊戲所需的三個文件:html
若是你想學習如何使用 JavaScript 編寫掃雷遊戲,第一步即是要理解遊戲是如何工做的。讓咱們直接從遊戲規則開始吧。前端
// 表示單元格的 JavaScript 代碼:
function Cell( row, column, opened, flagged, mined, neighborMineCount ) {
return {
id: row + "" + column,
row: row,
column: column,
opened: opened,
flagged: flagged,
mined: mined,
neighborMineCount: neighborMineCount
}
}
複製代碼
每一個單元格都是一個對象,包含如下屬性:java
id
相關的快捷方法。我可使用這些快捷方法,由於掃雷遊戲的面板較小,但這些代碼也不會考慮擴展到更大的遊戲面板上。若是你發現了,請在評論中指出來!// 表示遊戲面板的 JavaScript 代碼:
function Board( boardSize, mineCount ) {
var board = {};
for( var row = 0; row < boardSize; row++ )
{
for( var column = 0; column < boardSize; column++ )
{
board[row + "" + column] = Cell( row, column, false, false, false, 0 );
}
}
board = randomlyAssignMines( board, mineCount );
board = calculateNeighborMineCounts( board, boardSize );
return board;
}
複製代碼
咱們的遊戲面板是由單元格組成的集合。咱們能夠用許多不一樣的方式來表明咱們的遊戲面板。我選擇將它表示爲鍵值對形式的對象。正如咱們前面看到的,每一個單元格都有一個 id
用來做爲鍵。遊戲面板是這些惟一鍵和它們對應的單元格之間的映射。jquery
在建立了遊戲面板以後,咱們還須要完成另外兩項任務:隨機放置地雷並計算鄰近的地雷數量。咱們將在下一節詳細討論這些任務。android
// 隨機放置地雷的 JavaScript 代碼。
var randomlyAssignMines = function( board, mineCount ) {
var mineCooridinates = [];
for( var i = 0; i < mineCount; i++ )
{
var randomRowCoordinate = getRandomInteger( 0, boardSize );
var randomColumnCoordinate = getRandomInteger( 0, boardSize );
var cell = randomRowCoordinate + "" + randomColumnCoordinate;
while( mineCooridinates.includes( cell ) )
{
randomRowCoordinate = getRandomInteger( 0, boardSize );
randomColumnCoordinate = getRandomInteger( 0, boardSize );
cell = randomRowCoordinate + "" + randomColumnCoordinate;
}
mineCooridinates.push( cell );
board[cell].mined = true;
}
return board;
}
複製代碼
在掃雷遊戲開始以前,咱們要作的第一件事就是將地雷隨機放置到單元格。爲此,我建立了一個函數,該函數接收遊戲面板對象(board
)和所需的地雷計數(mineCount
)做爲參數。ios
對於咱們要放置的每個地雷,咱們生成隨機的行和列。此外,相同的行和列組合不該該重複出現。不然,咱們的地雷將少於咱們所指望的數目。若是出現重複,則必須從新隨機生成。git
當生成每一個隨機單元格座標時,咱們將對應單元格的 mined
屬性設置爲 true
。github
我建立了一個輔助函數,用來生成在咱們預期範圍內的隨機數。以下:
// 用來生成隨機數的輔助函數:
var getRandomInteger = function( min, max ) {
return Math.floor( Math.random() * ( max - min ) ) + min;
}
複製代碼
// 計算相鄰地雷數的 JavaScript 代碼:
var calculateNeighborMineCounts = function( board, boardSize ) {
var cell;
var neighborMineCount = 0;
for( var row = 0; row < boardSize; row++ )
{
for( var column = 0; column < boardSize; column++ )
{
var id = row + "" + column;
cell = board[id];
if( !cell.mined )
{
var neighbors = getNeighbors( id );
neighborMineCount = 0;
for( var i = 0; i < neighbors.length; i++ )
{
neighborMineCount += isMined( board, neighbors[i] );
}
cell.neighborMineCount = neighborMineCount;
}
}
}
return board;
}
複製代碼
如今讓咱們看看如何計算相鄰單元格的地雷數。
你會注意到,咱們循環遍歷了遊戲面板上的每一行和每一列,這是一種很是常見的方式。這樣咱們能夠每一個單元格上執行相同的處理。
咱們首先檢查每一個單元格是否放置了地雷。若是是,則不須要檢查相鄰的地雷數。畢竟,若是玩家點擊了它,他/她將會輸掉遊戲
若是單元格沒有被放置地雷,那麼咱們須要看看它周圍有多少地雷。咱們要作的第一件事是調用 getNeighbors
輔助函數,它返回相鄰單元格的 id
列表。而後咱們循環遍歷這個列表,累計地雷的數量,並更新單元格的 neighborMineCount
屬性。
讓咱們仔細看看 getNeighbors
函數,由於在整個代碼中它將被屢次調用。我以前提到過,個人一些設計方式是由於不用擴展到更大的遊戲面板上。這裏也是如此:
// 用於獲取掃雷車單元格的全部相鄰 id 的 JavaScript 代碼:
var getNeighbors = function( id ) {
var row = parseInt(id[0]);
var column = parseInt(id[1]);
var neighbors = [];
neighbors.push( (row - 1) + "" + (column - 1) );
neighbors.push( (row - 1) + "" + column );
neighbors.push( (row - 1) + "" + (column + 1) );
neighbors.push( row + "" + (column - 1) );
neighbors.push( row + "" + (column + 1) );
neighbors.push( (row + 1) + "" + (column - 1) );
neighbors.push( (row + 1) + "" + column );
neighbors.push( (row + 1) + "" + (column + 1) );
for( var i = 0; i < neighbors.length; i++)
{
if ( neighbors[i].length > 2 )
{
neighbors.splice(i, 1);
i--;
}
}
return neighbors
}
複製代碼
該函數接收單元格 id
做爲參數。而後咱們立刻把它分紅兩部分這樣咱們就有了行和列的值。咱們使用內置函數 parseInt
將字符串轉換爲整數。如今咱們能夠對它們進行數學運算了。
接下來,咱們使用行和列計算每一個相鄰單元格的 id
,並將它們加入列表。在處理狀況以前,列表中應該包含 8 個 id
。
一個單元格和它相鄰的單元格。
雖然這對於通常狀況是沒問題的,可是有一些特殊的狀況咱們須要考慮。也就是遊戲面板邊界的單元格。這些單元格的相鄰單元格數量會少於 8 個。
爲了解決這個問題,咱們循環遍歷相鄰單元格的 id
,並刪除長度大於 2 的 id
。全部無效的相鄰單元格行或者列多是 -1 或 10,因此很巧妙地解決了這個問題。
每當從列表中刪除 id
時,爲了保持它同步,咱們還必須減小索引變量。
好的,咱們在這一節還有最後一個函數要討論:isMined
。
// 檢查單元格是不是地雷的 JavaScript 函數:
var isMined = function( board, id ) {
var cell = board[id];
var mined = 0;
if( typeof cell !== 'undefined' )
{
mined = cell.mined ? 1 : 0;
}
return mined;
}
複製代碼
isMined
函數很是簡單。它只是檢查單元格是不是地雷。若是是,則返回 1;不然,返回 0。這個特性容許咱們在循環中反覆調用函數時,對函數的返回值進行累加。
這就完成了設置掃雷遊戲面板的算法。讓咱們進入真正的遊戲吧!
// 當單元格被翻開時執行的 JavaScript 代碼:
var handleClick = function( id ) {
if( !gameOver )
{
if( ctrlIsPressed )
{
handleCtrlClick( id );
}
else
{
var cell = board[id];
var $cell = $( '#' + id );
if( !cell.opened )
{
if( !cell.flagged )
{
if( cell.mined )
{
loss();
$cell.html( MINE ).css( 'color', 'red');
}
else
{
cell.opened = true;
if( cell.neighborMineCount > 0 )
{
var color = getNumberColor( cell.neighborMineCount );
$cell.html( cell.neighborMineCount ).css( 'color', color );
}
else
{
$cell.html( "" )
.css( 'background-image', 'radial-gradient(#e6e6e6,#c9c7c7)');
var neighbors = getNeighbors( id );
for( var i = 0; i < neighbors.length; i++ )
{
var neighbor = neighbors[i];
if( typeof board[neighbor] !== 'undefined' &&
!board[neighbor].flagged && !board[neighbor].opened )
{
handleClick( neighbor );
}
}
}
}
}
}
}
}
}
複製代碼
好吧,讓咱們直接進入這個刺激的操做。每當玩家點擊一個單元格時,咱們都會執行這個函數。它作了不少工做,還使用了遞歸。若是你不熟悉這個概念,請參閱如下定義:
Recursion:See recursion(不停地看)。
哈哈,真是計算機科學界的笑話。若是是在酒吧或咖啡廳這樣作老是有趣的。你真的應該在你暗戀的那個可愛的女孩身上試試。
總之,遞歸函數就是一個調用自身的函數。聽起來可能會發生堆棧溢出的問題,對嗎?這就是爲何你須要一個再也不進行任何後續遞歸調用的基本條件。咱們的函數最終將中止調用本身,由於再也不須要打開任何單元格。
在實際項目中,遞歸不多是正確的選擇,但它倒是一個頗有用的工具。咱們本能夠不使用遞歸來編寫這段代碼,但我想你們可能都想看看它的實際示例。
handleClick
函數接收單元格 id
做爲參數。咱們須要處理玩家在單擊單元格時同時按下 ctrl 鍵的狀況,可是咱們將在後面的部分討論這個問題。
假設遊戲尚未結束,咱們正在處理一個基本的左鍵單擊事件,咱們須要作一些檢查。若是玩家已經翻開或標記了這個單元格,咱們應該忽略此次點擊事件。由於若是玩家意外地點擊一個已經標記過的單元格而致使遊戲結束,這將會讓玩家感到沮喪。
不知足這兩個條件,那麼咱們將繼續。若是在單元格中存在地雷,咱們就須要去處理遊戲失敗的邏輯,並將爆炸的地雷顯示爲紅色。不然,咱們將把單元格設置爲打開的狀態。
若是打開的單元格周圍有地雷,咱們將以適當的字體顏色向玩家顯示鄰近的地雷數量。若是單元格周圍沒有地雷,那麼是時候使用遞歸了。在將單元格的背景顏色設置爲稍微暗一點的灰色以後,咱們對每一個未打開的而且沒有被標記的相鄰單元格調用 handleClick
。
讓咱們來看看 handleClick
函數中使用的輔助函數。咱們已經講過 getNeighbors
了,因此咱們從 loss
失函數開始。
// 當玩家輸掉遊戲時調用的 JavaScript 代碼:
var loss = function() {
gameOver = true;
$('#messageBox').text('Game Over!')
.css({'color':'white',
'background-color': 'red'});
var cells = Object.keys(board);
for( var i = 0; i < cells.length; i++ )
{
if( board[cells[i]].mined && !board[cells[i]].flagged )
{
$('#' + board[cells[i]].id ).html( MINE )
.css('color', 'black');
}
}
clearInterval(timeout);
}
複製代碼
當遊戲失敗,咱們設置全局變量 gameOver
的值,而後顯示一條消息,讓玩家知道遊戲已經結束。咱們還循環遍歷每一個單元格並顯示地雷出現的位置。而後咱們中止計時。
其次,咱們還有 getNumberColor
函數。這個函數負責給出相鄰單元格的地雷數顯示的顏色。
// 傳入一個數字並返回顏色的 JavaScript 代碼:
var getNumberColor = function( number ) {
var color = 'black';
if( number === 1 )
{
color = 'blue';
}
else if( number === 2 )
{
color = 'green';
}
else if( number === 3 )
{
color = 'red';
}
else if( number === 4 )
{
color = 'orange';
}
return color;
}
複製代碼
我試着把顏色搭配起來,就像經典的 Windows 版掃雷遊戲那樣。也許我應該用 switch 語句,但我已經不考慮遊戲被擴展的狀況了,這沒什麼大不了的。讓咱們繼續看看標記單元格的邏輯代碼。
// 用於在單元格上放置標記的 JavaScript 代碼:
var handleRightClick = function( id ) {
if( !gameOver )
{
var cell = board[id];
var $cell = $( '#' + id );
if( !cell.opened )
{
if( !cell.flagged && minesRemaining > 0 )
{
cell.flagged = true;
$cell.html( FLAG ).css( 'color', 'red');
minesRemaining--;
}
else if( cell.flagged )
{
cell.flagged = false;
$cell.html( "" ).css( 'color', 'black');
minesRemaining++;
}
$( '#mines-remaining').text( minesRemaining );
}
}
}
複製代碼
右鍵單擊一個單元格將在其上放置一個標記。若是玩家右鍵點擊了一個沒有被標記的單元格,而且當前遊戲還有剩餘的地雷須要被標記,咱們將在單元格上插上小紅旗做爲標記,並將其 flagged
屬性更新爲 true
,同時減小剩餘地雷的數量。若是單元格已經有了一個標誌,則執行相反的操做。最後,咱們更新顯示的剩餘地雷數量。
// 處理 ctrl + 左鍵的 JavaScript 代碼
var handleCtrlClick = function( id ) {
var cell = board[id];
var $cell = $( '#' + id );
if( cell.opened && cell.neighborMineCount > 0 )
{
var neighbors = getNeighbors( id );
var flagCount = 0;
var flaggedCells = [];
var neighbor;
for( var i = 0; i < neighbors.length; i++ )
{
neighbor = board[neighbors[i]];
if( neighbor.flagged )
{
flaggedCells.push( neighbor );
}
flagCount += neighbor.flagged;
}
var lost = false;
if( flagCount === cell.neighborMineCount )
{
for( i = 0; i < flaggedCells.length; i++ )
{
if( flaggedCells[i].flagged && !flaggedCells[i].mined )
{
loss();
lost = true;
break;
}
}
if( !lost )
{
for( var i = 0; i < neighbors.length; i++ )
{
neighbor = board[neighbors[i]];
if( !neighbor.flagged && !neighbor.opened )
{
ctrlIsPressed = false;
handleClick( neighbor.id );
}
}
}
}
}
}
複製代碼
咱們已經介紹了打開單元格和標記單元格的操做,因此讓咱們來介紹玩家能夠進行的最後一項操做:打開處於打開狀態單元格的相鄰單元格。handleCtrlClick
函數就是用來處理這個邏輯的。能夠經過按住 ctrl 並左鍵單擊一個處於打開狀態的且包含相鄰地雷的單元格來執行此操做。
若是這樣,咱們要作的第一件事是建立一個相鄰被標記的單元格列表。若是相鄰被標記單元格的數量與周圍地雷的實際數量相匹配,那麼咱們繼續。不然,咱們什麼也不作,直接退出函數。
若是繼續,接下來要作的就是檢查被標記的單元格中是否包含地雷。若是是,咱們便知道玩家錯誤地預測了地雷的位置,而且將要翻開全部未標記的相鄰單元格致使遊戲失敗。咱們須要設置局部變量 lost
的值並調用 loss
函數。前面已經討論了 loss
函數。
若是遊戲仍然沒有失敗,那麼咱們將須要打開全部未標記的相鄰單元格。咱們只須要循環遍歷它們,並在每一個函數上調用 handleClick
函數。可是,咱們必須首先將 ctrlIsPressed
變量設置爲 false
,以防止錯誤地執行 handleCtrlClick
函數。
咱們幾乎完成了對編寫掃雷遊戲所需的全部 JavaScript 邏輯的分析!剩下要討論的就是開始新遊戲所需的初始化步驟。
// 用於初始化掃雷遊戲的 JavaScript 代碼
var FLAG = "⚑";
var MINE = "⚙";
var boardSize = 10;
var mines = 10;
var timer = 0;
var timeout;
var minesRemaining;
$(document).keydown(function(event){
if(event.ctrlKey)
ctrlIsPressed = true;
});
$(document).keyup(function(){
ctrlIsPressed = false;
});
var ctrlIsPressed = false;
var board = newGame( boardSize, mines );
$('#new-game-button').click( function(){
board = newGame( boardSize, mines );
})
複製代碼
咱們要作的第一件事就是初始化一些變量。咱們須要定義常量來存儲小旗和地雷圖標的 html 代碼。咱們還須要一些常量來存儲遊戲面板的大小、地雷的總數、計時器和剩餘地雷的數量。
此外,若是玩家按下 ctrl 鍵,咱們須要一個變量來存儲是否按下了 ctrl 鍵。咱們使用 jQuery 將事件處理程序添加到 document
中,用來設置 ctrlIsPressed
變量的值。
最後,咱們調用 newGame
函數並將該函數綁定到 new game 按鈕。
// 開始新的掃雷遊戲的 JavaScript 代碼
var newGame = function( boardSize, mines ) {
$('#time').text("0");
$('#messageBox').text('Make a Move!')
.css({'color': 'rgb(255, 255, 153)',
'background-color': 'rgb(102, 178, 255)'});
minesRemaining = mines;
$( '#mines-remaining').text( minesRemaining );
gameOver = false;
initializeCells( boardSize );
board = Board( boardSize, mines );
timer = 0;
clearInterval(timeout);
timeout = setInterval(function () {
// This will be executed after 1,000 milliseconds
timer++;
if( timer >= 999 )
{
timer = 999;
}
$('#time').text(timer);
}, 1000);
return board;
}
複製代碼
newGame
函數負責重置變量,使咱們的遊戲處於隨時能夠玩的狀態。這包括重置顯示給玩家的消息、調用 initializeCells
,以及建立一個新的隨機遊戲面板。它還包括重置時計時器,而且每秒鐘更新一次。
讓咱們經過看 initializeCells
來總結一下。
// 用於將單擊處理程序附加到單元格並檢查勝利條件的 JavaScript 代碼
var initializeCells = function( boardSize ) {
var row = 0;
var column = 0;
$( ".cell" ).each( function(){
$(this).attr( "id", row + "" + column ).css('color', 'black').text("");
$('#' + row + "" + column ).css('background-image',
'radial-gradient(#fff,#e6e6e6)');
column++;
if( column >= boardSize )
{
column = 0;
row++;
}
$(this).off().click(function(e) {
handleClick( $(this).attr("id") );
var isVictory = true;
var cells = Object.keys(board);
for( var i = 0; i < cells.length; i++ )
{
if( !board[cells[i]].mined )
{
if( !board[cells[i]].opened )
{
isVictory = false;
break;
}
}
}
if( isVictory )
{
gameOver = true;
$('#messageBox').text('You Win!').css({'color': 'white',
'background-color': 'green'});
clearInterval( timeout );
}
});
$(this).contextmenu(function(e) {
handleRightClick( $(this).attr("id") );
return false;
});
})
}
複製代碼
這個函數的主要目的是向單元格 DOM 對象添加額外的屬性。每一個單元格 DOM 都須要添加對應的 id,以便咱們可以從遊戲邏輯中輕鬆地訪問它。每一個單元格還須要一個合適的背景圖像。
咱們還須要爲每一個單元格 DOM 添加一個單擊處理程序,以便可以監聽左擊和右擊事件。
處理左擊事件調用 handleClick
函數,傳入對應的 id
。而後檢查是否每一個沒有地雷的單元格都被打開了。若是這是真的,那麼遊戲勝利,咱們能夠適當地祝賀一下他/她。
處理右擊事件調用 handleRightClick
,一樣傳入對應的 id
,而後返回 false
。這樣會阻止 Web 頁面右鍵單擊顯示上下文菜單的默認行爲。對於通常的 CRUD 應用程序,你可能不但願這樣處理,可是對於掃雷遊戲,這是合適的。
祝賀你,已經學習瞭如何使用 JavaScript 編寫掃雷遊戲!看起來有不少的代碼,但但願咱們把它分解成這樣不一樣的模塊,是有意義的。咱們確定能夠對這個程序的可重用性、可擴展性和可讀性作更多的改進。咱們也沒有詳細介紹 HTML 或 CSS 代碼。若是你有任何問題或有改進代碼的方法,我很樂意在評論中聽到你的意見!
若是這篇文章讓你想要更多地瞭解如何用 JavaScript 編寫更好的程序,我推薦一本 JavaScript 書:《JavaScript 語言精粹》,做者是 Douglas Crockford。他將 JSON 推廣爲一種數據交換的格式,併爲 Web 的發展作出了巨大貢獻。
多年來,該 JavaScript 語言獲得了極大的改進,但因爲其發展的歷史,它仍然具備一些奇怪的特性。這本書會幫助你更好的理解這本語言在設計上存在的問題(如全局命名空間)。當我第一次學習這門語言時,我發現它頗有幫助。
若是你決定擁有它,而且經過上面的連接購買我會很是地感謝你。我將經過亞馬遜的會員計劃得到一些佣金,不須要你付額外的費用。它將幫助我維護這個網站的正常運行,而不用求助於煩人的廣告。我寧願推薦我認爲對大家有幫助的產品。
好了,廣告到此爲止。我但願大家有一個愉快的閱讀體驗。讓我知道你還想看什麼其餘相似的簡單遊戲,不要忘記留下你的電子郵件,這樣你就不會錯過寫一篇文章。你還會收到個人免費推送內容,如何更好地編寫函數。
祝好!
更新(2019/7/13日):這篇文章比我想象的更受歡迎,太棒了!我從讀者那裏收到了不少關於能夠改進的方面的反饋。我天天都在作維護一個代碼庫的工做,直到如今這個代碼庫還停留在 Internet Explorer 怪異模式。我在工做中的許多編碼習慣都轉移到了我在掃雷遊戲上,致使一些代碼沒有利用 JavaScript 技術的前沿。以後,我想在另外一篇文章中重構代碼。我計劃徹底刪除 jQuery,並在適當的地方使用 ES6 語法而不是 ES5。但你不用等我!看看你本身可否完成這些工做!請在評論中告訴我進展如何。
若是發現譯文存在錯誤或其餘須要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可得到相應獎勵積分。文章開頭的 本文永久連接 即爲本文在 GitHub 上的 MarkDown 連接。
掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源爲 掘金 上的英文分享文章。內容覆蓋 Android、iOS、前端、後端、區塊鏈、產品、設計、人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃、官方微博、知乎專欄。