第9章 综合实训——HTML5扫雷游戏

9.1 扫雷游戏规则


扫雷是一款经典的计算机游戏。游戏界面中有若干行和若干列的方格,其中每个方格中都可能有地雷,玩家需要根据线索通过单击找出所有地雷,玩家可以根据没有地雷的方格上显示的数字推测地雷的位置。

  • 9.1.1 界面呈现
  • 9.1.2 基本操作
  • 9.1.3 高级操作
  • 9.1.4 信息显示
  • 9.1.5 胜利条件

9.1.1 界面呈现


游戏的开始界面包含若干个空白的方格。下图中展示了8*8的空白方格。

每个空白方格都可能有地雷或没有地雷。没有地雷的方格可分为直接与有地雷方格相邻的方格(包括斜对角相邻)和不直接与有地雷方格相邻的方格。下图展示了过关后的游戏界面,图中的旗子符号是玩家用以标记地雷的。空白的方格(如左上角的方格)是既没有地雷,也不靠近地雷的方格。靠近地雷的方格(包括斜角相邻)中会显示一个数字,它是当前方格周围8个方格中地雷的数量。方格中的数字可以是1——8的任意一个数字。

9.1.2 基本操作


游戏基本操作是单击未打开的方格,如这个方格中有雷,则游戏失败;如这个方格中没有地雷,则该方格的信息会显示出来。

关于信息如果这个格子周围有地雷,则仅展示这个格子上的数字。如果这个格子周围没有地雷,则从这个格子开始打开周围的格子,遇到空白格子仍继续打开周围格子,遇到有数字的格子则打开这个数字格子然后停止。

下图展示了一个因为点到地雷而失败的游戏界面。游戏失败后会显示出所有用户未找到的地雷,并高亮用户误点的地雷。

右键点击空白格子可以把这个格子标记为地雷(通常用旗子记号表示),标错地雷通常不会立刻引发问题。玩家随时可以在标记为地雷的格子上再点右键取消地雷标记。

9.1.3 高级操作


对于有数字的格子,右键点击意味着玩家认为这个格子周围的地雷已经全部被标出了。程序会先检查格子周边标出的地雷数,如果标出的雷和格子上的数字相等,游戏自动帮玩家点开这个格子周围的未打开的格子。但如果玩家标错了雷的位置,这时会导致游戏失败。

但如果玩家标记的雷数和格子上的数字不一致,右键点击不会触发操作。如下图中已经标记出了一个地雷。

此时如右键单击左上的“1”格子,不会发生任何动作。右键单击左上格子右边相邻的“1”格子,则会把这个这两个“1”下方的格子都打开。如下图所示。

这个操作可以提高玩家操作效率,增强游戏体验,还能辅助玩家判断一个格子周围的雷是不是都被标记出了。

9.1.4 信息显示


为了提示玩家,可以显示剩余未标记出的雷的个数,但这通常是基于玩家标记的雷的数量,而不是玩家标记的正确的雷的数量,否则玩家可以根据这个数字猜测雷的位置。

为了游戏体验,还可以显示游戏进行的时间,并通过游戏完成时间作为游戏成绩。

9.1.5 胜利条件


游戏胜利条件是玩家标记出了所有地雷的位置,且没有任何错误的标记。这仅以当前标记的状态为准,如果玩家游戏过程中曾标记错误,但又通过取消标记纠正,则不算错误标记。

9.2 绘制游戏界面


游戏使用的方格和前面介绍的2048中的方格类似,可以使用SVG绘制。图中的数字,旗子,地雷的标志都可以通过SVG的标签以文本的形式绘制。

  • 9.2.1 绘制方格
  • 9.2.2 绘制点击后的背景
  • 9.2.3 绘制地雷、旗子和数字

9.2.1 绘制方格


先通过HTML代码创建SVG画布。其中包含四个标签,分别用于放置不同的内容。代码如下。


							 <body style='margin: 0; padding:0; border:0'>
								 <svg id='svg'>
									 
									 
									 
									 
								 </svg>
							 </body>
						 

第一个g用于放置游戏界面的背景,第二个用于放置未被点击过的格子背景。第三个用于放置打开的格子的背景以及这上面的数字或地雷。第四个用于放置旗子。通过body的style设置margin、padding和border为0。下面代码用于设定<svg>标签的大小充满屏幕。


let w = window.innerWidth;// 获取窗口宽度
let h = window.innerHeight; // 获取窗口高度
svg.style.width = w + "px";// 设置 SVG 元素宽度
svg.style.height = h + "px"; // 设置 SVG 元素高度
						

下面的代码用于生成背景和方格。


let row = 15;// 格子行数
let col = 15;// 格子列数
let space = 6;// 格子间隔(px为单位)
let cellWidth = 40;// 格子宽度(px为单位)
function createBackground() {
// 放置作为游戏背景的大长方形
g1.innerHTML += "<rect width='" + w + "' height='" + h + "' style='fill:#808080;stroke-width:1;stroke:rgb(0,0,0);margin: 0; padding:0; border:0'/>";
// 清空方格背景
g2.innerHTML = '';
for (let i = 0; i < row; i++) {
let x = i * cellWidth + space * (i+1);
for (let j = 0; j < col; j++) {
let y = j * cellWidth + space * (j+1);
g2.innerHTML += "<rect x='" + x + "' y='" + y + "' width='" + cellWidth + "' height='" + cellWidth + "' style='fill:#c0c0c0;stroke-width:1;stroke:rgb(224, 224, 235)'/>";
}
}
}
createBackground(); // 调用函数创建背景
						

方格使用<rect>标签,格子的行数,列数,格子间距,格子宽度都在函数外的以变量的形式设置。

9.2.2 绘制点击后的背景


点击后的背景颜色将会变化以区分点过和未点过的方格。这里的方格也使用<rect>绘制,只有颜色不同。所以可以使用一个函数拼接生成<rect>标签。


						createCellHTML(x, y, color, id) {
							if (id) { 
								id = 'id="' + id + '" '
							}
							else {
								id = '';
							}
							return `<rect ${id}x="${x}" y="${y}" width="${cellWidth}" height="${cellWidth}" style="fill:${color};stroke-width:1;stroke:rgb(224, 224, 235)"/>`; 
						}
						

函数除了接受方格的位置参数x和y,以及方格背景颜色color外,还接受一个可选参数id。如果id为空则不会插入id,如果id不为空则给这个rect增加id参数。所以前一小节的背景方格可以使用参数createCellHTML(x, y, "#c0c0c0")。对于点击开的方格可使用参数this.createCellHTML(x, y, "#a0a0a0")。对点击到地雷的方格使用红色背景:createCellHTML(x, y, "red")。

9.2.3 绘制地雷、旗子和数字


地雷和旗子可以使用符号“💣”和“🚩”表示,它们都是Unicode字符,字符编码分别为“U+1F4A3”和“U+1F6A9”。下面的代码用于绘制文字,可以用于生成地雷,旗子和数字的<text>的标签。


							createTextHTML(x, y, text, id) {
								let fontSize = cellWidth/5*3;
								if (id) {
									id = 'id="' + id + '" ';
								}
								else {
									id = ''
								}
								let xx = x + (cellWidth*1/7);
								let yy = y + (cellWidth*7/10);
								let fill = '';
								if (colorSet[text]) { // 是1到8的数字
									fill = 'fill="'+colorSet[text]+'" ';
									fontSize = cellWidth/5*4; // 对数字使用大一些的字号
									xx = x + (cellWidth*2/7);
								yy = y + (cellWidth*4/5);
								}
								return `${text}`;
							}
							

上面代码中使用到了colorSet变量保存了从1到8的数字所使用的配色。它是一个函数外的变量,它的定义如下。


							let colorSet = [null, '#0000ff', '#008000', '#cc0000', '#000080', '#800000', '#008080', '#9900ff', '#000000'];
							

数字下标0的位置设成null。下标1到8分别是1到8这八个数字的颜色。绘制这三种元素可以使用下面的代码。


createTextHTML(x, y, '💣')
createTextHTML(x, y, '🚩')
createTextHTML(x, y, 1)
								

如果是1到8的数字会自动增加颜色属性,并把字号调大一些,并调整位置使字的位置居中。

9.3 游戏状态


要通过程序记录游戏状态:一是地雷的位置,提示数字,还有每个格子被打开和被标记的状态。

  • 9.3.1 地雷位置
  • 9.3.2 计算提示数字
  • 9.3.3 记录格子状态

9.3.1 地雷位置


可使用二维数组保存地雷的位置,通常游戏开始时就要设定好所有地雷的位置,等玩家操作时程序通过检查这个二维数组的内容决定如何反应。

生成地雷位置通常需要使用随机数生成函数,这样使得每次游戏地雷的位置带有一定随机性。下面代码使用Math.random()实现了一个随机数生成函数。


							function getRandom(n) {
								return Math.round(Math.random() * n);
							}
						

函数接收一个整数参数n,返回0到n之间的随机数(包括0和n)。

下面的代码生成了一个row行col列的二位数组,数组内的数字都是0。


let mp = [];
for (let i = 0; i < row; i ++) {
    mp.push([]);
    state.push([]);
    for (let j = 0; j < col; j++) {
      mp[i].push(0);
      state[i].push(0);
    }
}
						

首先随机选择一定数量的位置设置为地雷。地雷设置好就可以计算出提示数字,即无雷的格子周围有几个地雷。格子周围最多有8个雷,所以对于数组mp可以用其中的数字代表无雷的格子周围的雷数(也就是显示给玩家的提示数字)。格子里数字如果为0,就意味着这个格子周围没有雷。

为了方便,可以利用数字9代表地雷。所以先随机找一些格子,把这些格子中的数字设为9。下面代码实现了设置地雷的逻辑。


let mine_count = 20;// 地雷数量
for (let i = 0; i < mine_count; i++) {
  while (true) {
    let x = get_random(row-1);
    let y = get_random(col-1);
    if (mp[x][y] != 9) {
      mp[x][y] = 9;
      break;
    }
  }
}
													

9.3.2 计算提示数字


设置完地雷后,再通过遍历整个二维数组把提示数字预先计算好。计算方法是遍历整个二维数组,遇到地雷后,就把周围的8个方向上的格子上的数字都加一(地雷和超过边界的情况除外)。下面代码定义了8个方向,即当前数组下标加上这两个值后就到达一个相邻的格子。


							let directions = [[0, 1], [1, 0], [1, 1], [0, -1], [-1, 0], [-1, -1], [1, -1], [-1, 1]];
						

下面的代码实现了根据地雷位置计算提示数字的逻辑。


							for (let i = 0; i < this.row; i ++) {
								for (let j = 0; j < this.col; j++) {
									if (this.mp[i][j] == 9) {
										for (let k = 0; k < directions.length; k++) {
											let x = i + directions[k][0];
											let y = j + directions[k][1];
											if (x > -1 && x < this.row && // x 没有越界
												y > -1 && y < this.col && // y 没有越界
												this.mp[x][y] != 9// 这个位置不是地雷
											) {
												this.mp[x][y] += 1;
											}
										}
									}
								}
							}
						

9.3.3 记录格子状态


格子的状态有未点击过,点击过,标记为地雷三种。可创建另一个二维数组存放这些状态。一个格子同时只可能处在这三个状态中的一个。

使用二维数组state描述格子状态。0代表未点击过。-1代表点击过,1代表标记为地雷。

9.4 处理用户操作


需要实现接收和处理用户输入,查找当前游戏状态,根据游戏规则和用户操作决定操作结果并给用户反馈。当游戏结束(胜利或失败)时给用户提示。

  • 9.4.1 处理左键单击事件
  • 9.4.2 处理右键单击事件
  • 9.4.3 判断过关条件
  • 9.4.4 过关后禁止操作

9.4.1 处理左键单击事件


左键单击格子,如果格子有地雷则游戏失败,如果格子没有地雷,显示这个格子的提示数字,如果这个格子周围无地雷(提示数字为0)则通常不显示数字,而递归地打开其周围的格子,直到打开有数组的格子为止。

需要用函数实现上面的逻辑并把左键点击事件绑定到这个函数。为了方便,可使用时间托管的方法,即把点击事件绑定在放置格子的<g>标签中。因为事件冒泡机制,点击格子后<g>标签可以收到这个事件。但需要给格子添加一些属性,让事件处理函数可以得出格子的位置。下面的函数给格子增加了id参数,这个id使用字符串'cell_'和格子的行列下标拼接而成。


							createCellHTML(x, y, "#c0c0c0", 'cell_' + i + '-' + j);
						

事件处理函数可以读取event.target的id后切分字符串得到这个格子的位置。然后可到游戏状态的二维数组中查询这个格子状态从而给用户反馈。下面的代码用于根据id获取格子的位置。


							function clickCell(event, i, j) {
								if (event) {
								    let id = event.target.id;
								    id = id.split('_')[1].split('-');
							      i = parseInt(id[0]);
						       j = parseInt(id[1]);
								}
							}
						

这个函数有三个参数,但后两个参数i和j是可选的,在作为事件处理函数时,只是用第一个参数。但在点击了空白格子的情况下,需要递归展开,则通过参数i和j调用,event可设为null。

下面的代码时得到格子位置和针对格子状态和地图状态做出的判断和给用户的反馈。


							if (state[i][j] !== -1) {
								      let x = i * cellWidth + space * (i+1);
								      let y = j * cellWidth + space * (j+1);
								      if (mp[i][j] === 9) {
								            g3.innerHTML += createCellHTML(x, y, "red");
								            g3.innerHTML += createTextHTML(x, y, '💣');            
								            state[i][j] = -1;
								            for (let ii = 0; ii < row; ii++) {
								                  x = ii * cellWidth + space * (ii+1);
								                  for (let jj = 0; jj < col; jj++) {
								                        if ((ii != i || j != jj) && mp[ii][jj] == 9 && state[ii][jj] == 0) {
								                              y = jj * cellWidth + space * (jj+1);
								                              g3.innerHTML += createCellHTML(x, y, '#a0a0a0');
								                             g3.innerHTML += createTextHTML(x, y, '💣');
								                        }
								                  }
								            }
								            setTimeout(()=>{alert("Game over!")},0);
								      }
								      else if (mp[i][j] === 0) {
								            g3.innerHTML += createCellHTML(x, y, "#a0a0a0");
								            for (let k = 0; k < directions.length; k++) {
								                  let x = i + directions[k][0];
								                  let y = j + directions[k][1];
								                  if (x > -1 && x < row && y > -1 && y < col && mp[x][y] != 9) {
								                        state[i][j] = -1;
								                        clickCell(null, x, y);
								                  }
								            }
								      }
								      else {
								            g3.innerHTML += createCellHTML(x, y, "#a0a0a0");
								            g3.innerHTML += createTextHTML(x, y, mp[i][j]);
								            state[i][j] = -1;
								      }
								}
						

在g3中插入的内容是地雷或者提示数字。当点击到地雷时则游戏结束,显示出所有地雷,点击到地雷的格子背景设为红色。

点击到非地雷格子时,显示出数字或者递归打开其他格子,递归打开的方式就是调用这个函数本身。至此已经实现地图的创建,游戏状态记录和左键点击事件的处理。下图展示了现在实现的界面效果。

左键单击未打开的方块遇到地雷触发游戏失败,遇到有数字的方格只显示一个方格的数字,遇到空方格则递归展开周围方格。

9.4.2 处理右键单击事件


下面的代码创建了用于处理右键单击事件的函数,与左键单击函数类似地,先通过event地target元素拿到id,根据id解析出格子位置。再通过state数组检查格子状态,只有为打开的格子才能执行这个操作。


							  function onContextmenu(event) {
								      let id = event.target.id;
								      id = id.split('_')[1].split('-');
								      let i = parseInt(id[0]);
								      let j = parseInt(id[1]);
								      event.preventDefault(); // 阻止默认事件,即不要弹出右键菜单
								      if (state[i][j] !== -1) { // 只能在未打开的格子进行操作
								            if (state[i][j] == 0) { // 如果格子上没有标记则添加标记
								                  let x = i * cellWidth + space * (i+1);
								                  let y = j * cellWidth + space * (j+1);
								                  state[i][j] = 1; // 标记当前格子状态为有标记
								                  g4.innerHTML += createTextHTML(x, y, '🚩', 'flag_'+i+'-'+j);
								            }
								            else { // 格子上已有标记则取消标记
								                  state[i][j] = 0; // 标记当前格子状态为无标记
								                  g4.innerHTML = ''; // 清空所有标记
								                  for (let i = 0; i < row; i++) { // 重新绘制现存标记
								                        let x = i * cellWidth + space * (i+1);
								                        for (let j = 0; j < col; j++) {
								                              if (state[i][j] == 1) {
								                                    let y = j * cellWidth + space * (j+1);
								                                    g4.innerHTML += createTextHTML(x, y, '🚩', 'flag_'+i+'-'+j);
								                              }
								                        }
								                  }
								            }
								      }
								}
						

当格子时未打开状态时(state值不等于-1),进而检查该位置是否已经有旗子,如果没有旗子则修改标记,记为有旗子,并绘制旗子。若已经有旗子,则当前操作要取消这个旗子,应该清空所有旗子(清空g4中的内容)并按照state重新绘制所有旗子。

下面代码把g2和g4的右键事件绑定到这个函数。


							g2.addEventListener('contextmenu', onContextmenu);
       g4.addEventListener('contextmenu', onContextmenu);
					  

9.4.3 判断过关条件


前面介绍过,过关条件是玩家找到所有地雷位置,并且这时没有任何错误标记的地雷。下面代码实现了检查是否过关的逻辑。这段逻辑应该放到右键单击事件中,添加旗子标记的if分支中。即每次玩家标记新的地雷后,检查是否过关。


			              let currentMineCnt = 0; // 当前地雷数量
                 let falseMine = 0;     // 错误标记的数量
                 for (let i = 0; i < row; i++) {
                     for (let j = 0; j < col; j++) {
                        if (state[i][j] === 1) {
                            if (mp[i][j] === 9) {
                                currentMineCnt++;
                            }
                            else {
                                falseMine++;
                            }
                        }
                     }
                  }
                  if (falseMine === 0 && currentMineCnt === mineCnt) {
                      alert("恭喜过关!");
                  }
					    

定义两个变量,当前地雷数量和错误标记的地雷数量。通过遍历一次地图,结合mp和state中的数据即可得到这两个的值,当前雷数量等于地图中地雷数量且错误标记的数量为0时,显示过关。

下图展示了过关时的提示,此时用户右键点击了图中提示数字“4”下方的最后一个空格子,页面弹出提示框。玩家点击确定后,旗子符号被显示在右键单击的位置。

如果希望实现先出现地雷后出现提示的效果则可以把弹出提示框的操作放到单独的函数,并通过setTimeout函数延迟调用,并把延迟实现设置为0毫秒。下面函数实现了这一功能。


							  function delayAlert(msg, timeout=0) {
								      setTimeout(()=>{
								            alert(msg);
								      }, timeout);
								 }
						

9.4.4 过关后禁止操作


定义变量active表示当前游戏是否是活动的。当游戏结束把这个变量设为false,在处理单击和右键事件时,如果这个值时false则不执行操作,而提示用户游戏已经结束。

此外在makeMap函数中,判断如果active为true则不能更新地图,因为游戏正在进行中。当active为false时更新地图,并把active设为true表明新的游戏开始了。

9.5 使用class组织代码


9.4节完成后,游戏的主要功能均已实现,目前代码中有8个函数和5个用于配置变量,一个用于描述地图上8个方向的数组directions,一个用于保存提示数字配色的数组colorSet。还需要调用createBackground,makeMap两个函数,并绑定三个事件。使用class关键字创建一个类可以把这些函数和变量放置在类中,使代码更容易管理。

  • 9.5.1 类的构造函数
  • 9.5.2 事件处理函数
  • 9.5.3 确保第一次点击不会点到地雷

9.5.1 类的构造函数


类的构造函数用于在创建这个类的变量时提供参数并根据参数初始化这个变量。下面的代码定义了名为MineSwapper的类和它的构造函数,构造函数接受6个参数。这六个参数分别是游戏地图中格子的行数,列数,地雷数量,格子宽度,格子间隔,提示数字配色,这些参数可以在创建这个类的对象时提供,并存储在这个对象内部,而不会直接出现在全局范围内了。


							class MineSwapper {
								constructor(row, col, mineCnt, cellWidth, space, colorSet) {
								    this.directions = [[0, 1], [1, 0], [1, 1], [0, -1], [-1, 0], [-1, -1], [1, -1], [-1, 1]];
								    this.colorSet = colorSet;
								    if (!this.colorSet) {
								            this.colorSet = [null, '#0000ff', '#008000', '#cc0000', '#000080', '#800000', '#008080', '#9900ff', '#000000'];
								    }
								    this.space = space;
								    this.cellWidth = cellWidth;
								    this.row = row;
								    this.col = col;
								    this.mineCnt = mineCnt;
								    this.mp = [];
								    this.state = [];
								         g1.innerHTML += "";
								    svg.style.height = h + "px";
								    svg.style.width = w + "px";
								    this.createBackground()
								    this.active = false;
								}
							}
					  

在类内的方法中,使用this指针访问类的成员变量。创建MineSwapper的方法如下面代码所示。


							let game = new MineSwapper(15/*行数*/, 15/*列数*/, 20/*地雷数*/, 40/*格子宽度*/, 6/*格子间隔*/);
					  

代码中的注释展示出每个参数的含义。

9.5.2 事件处理函数


上一小节把用于配置的变量都放到类里,还可以把除了随机数函数外的所有函数都移到类中,因为随机数函数并非和游戏逻辑直接相关。

可以在类构造函数中直接绑定事件处理函数,但类的方法被作为事件处理函数调用后,其this指针变成指向当前绑定的元素对象,而不再是类的对象,所以无法访问到类的成员变量和方法。所以需要稍作处理如下面代码所示。


					               g2.addEventListener('click', (event) => {this.clickCell(event)});
                    g2.addEventListener('contextmenu', (event) => {this.onContextmenu(event)});
                    g4.addEventListener('contextmenu', (event) => {this.onContextmenu(event)});
					  

这里定义了一个匿名函数,在匿名函数中的再调用类的成员函数即可实现事件处理函数中clickCell和onContextmenu中的this还是指向当前类的对象。

9.5.3 确保第一次点击不会点到地雷


之前的游戏逻辑是页面加载时就计算好地雷的位置。但用户第一次操作时,面对的时完全空白的地图,只能随便点击,有一定概率开局就点到地雷。如前面的设定,15行,15列供225个格子中包含20个地雷,第一次点击直接点到地雷的概率是20除以225大约是8.89%。

一个可行的优化是等用户第一次点击时才开始放置地雷,这样,放置地雷时就可以避开用户点击的位置,确保第一次点击一定不会点到地雷。

若要实现这一策略,再新增一个状态inited表明当前游戏是否已经初始化。如果游戏还没有初始化,第一次点击时将调用makeMap生成地图,且makeMap接收两个参数,即用户点击位置的x和y,放置地雷时避开这个位置。

9.6 小结


本章实现了扫雷小游戏。使用SVG生成游戏界面,通过事件托管处理格子上的左键右键点击事件。最后使用class重新组织代码使代码结构更严谨。