用 Vue.js 写一个扫雷

您所在的位置:网站首页 用python做扫雷简单游戏 用 Vue.js 写一个扫雷

用 Vue.js 写一个扫雷

2024-07-16 11:41| 来源: 网络整理| 查看: 265

代码放在 CodePen 上:https://codepen.io/dinnerwithouttomato/pen/BazzaZK

体验地址:https://qiutongxue.gitee.io/webpage/minesweeper/

扫雷的需求分析

我们打开扫雷玩几把,可以发现扫雷的大概流程是这样的:

点击任一格子开始游戏,且点的第一下必定不是雷点的若是数字,不会扩散。 no-spread点的若是空白,会扩散,并且扩散到数字即停止 spread有剩余地雷提示,每插一个旗子少 1,还能变成负数点击到地雷,游戏结束(失败),计时停止,显示所有地雷,且踩到的地雷有额外标识(红色背景) 地雷的额外标识安全格子全部点完,游戏结束(胜利),计时停止,所有地雷自动插旗。 success

不难看出,扫雷的基本逻辑还是很简单的,“地图“可以用二维数组表示,地雷用 -1 表示,空白格用 0 表示,数字格就用相应的数字表示。点击空白格之后的扩散现象其实就是一个搜索的过程,DFS、BFS 都能实现。其它的细节在下一节会提及。

跟着我的节奏,虫! 造个界面

游戏没有界面是不行的!最基本的开始按钮要有吧,基本的网格也要有吧。当然细节上不用着急去深挖,先把整体的一个格子造起来:

{{ btnContent }} {{ cell }}

OK,div 还挺多的,不着急,一个个来。首先最外层的 div 是为了给 Vue 挂载的,让 Vue 知道,哦我控制的是这一块区域。下面一层是主界面区域,大概就是包含了按钮、文本、方格之类的。按钮的部分就不说了,与按钮同级的是游戏区域,也就是真正能点的地方了。这里我把这些小方格全都按照 div 来处理,并且是一行一行排列的,minesArray 是一个二维数组,记录每个位置表示什么(-1 表示地雷,0 表示空格……)。使用 v-for 遍历 minesArray,这样数组有多大,游戏区域就有多大。这样就能组成一个网格了…吗?

当然不是,首先 div 是无形无影的,其次因为行高的原因,行与行之间有一定的距离,这就完全不像网格!出于顺眼的考虑,就先小小的美化一下:

.main-area { --cell-size: 20px; } .game-area { margin: 10px; } .cell { display: inline-block; width: var(--cell-size); height: var(--cell-size); line-height: var(--cell-size); border: 1px solid; text-align: center; vertical-align: middle; cursor: pointer; } .row-cells { font-size: 1px; }

在 .cell 中有几点需要提一下, inline-height 设置和 height 相同的值,是为了让格子里的数字能够垂直居中。 vertical-align 为了让每个格子能垂直对齐(当格子里有数字时格子会下沉?)。

另外,因为网格的关系,也可以使用 display: grid,网格布局或许会更容易(?)一些。

现在就舒服多了嘛:

然后在把 Vue 挂载到 #app 上:

var vm = new Vue({ el: "#app", data: { isGameOver: false, isFirstClick: true, minesArray: '', rowSize: 8, colSize: 8, mineSize: 9, btnContent: 'emoji-smile', //timer: '', //time: 0.0, //visited: '', //noMineBlocks: '' }, methods: { clickCell(row, col) { /* 网格点击事件 */ }, }, mounted() {}, filters: {} })

rolSize 和 colSize 分别为网格的行数和列数,初级扫雷是一个 8 × 8 的网格。mineSize 是地雷的数量,初级扫雷为 9 个

开始写代码 初始化网格

在 html 中,网格的大小取决于 minesArray 的大小,所以确定了 minesArray 才能把网格绘制出来。可以使用 mounted(),在 Vue 挂载之后就自动初始化:

mounted() { // 初始化游戏 console.log("-----------------------"); console.log("初始化游戏中..."); this.minesArray = new Array(); // this.visited = new Array(); for (let i = 0; i { cell }}{% endraw %} {{ cell }} ...

根据 minesArray 返回的结果,各数字颜色对应各类,比如数字 1 对应 num-color-1,数字 -1 对应 num-color--1,在 css 中对样式进行定义:

.num-color { font-weight: bold; } .num-color-0 { color: darkgrey; } .num-color-1 { color: blue; } .num-color-2 { color: green; } .num-color-3 { color: red; } .num-color-4 { color: darkblue; } .num-color-5 { color: darkred; } .num-color-6 { color: darkcyan; } .num-color-7 { color: black; } .num-color-8 { color: gray; } .num-color--1 { background: red; } .num-color--2 { background: greenyellow; }

着色后

现在看着舒服多了,有点内味儿了。

把数字藏起来

数字布置完成,接下来应该把格子的数字藏起来,等我点击的时候再出现。用一个存放 boolean 值的二维数组 visited 记录哪些方格被访问过了,访问过的方格就把数字显示出来。

现在 data 中声明好 visited:

data: { ... visited: '' }

然后初始化该数组(和 minesArray 一起放在 mounted 中初始化):

mounted() { // 初始化游戏 console.log("-----------------------"); console.log("初始化游戏中..."); this.minesArray = new Array(); this.visited = new Array(); for (let i = 0; i {{ cell | cellFilter}}

设置 mask 和 num-color 的 css 样式:

.num-color { position: absolute; font-weight: bold; line-height: var(--cell-size); width: inherit; height: inherit; /* background: #c0c0c0; box-shadow: 1px 1px #808080 inset; */ } .mask { position: absolute; width: inherit; height: inherit; z-index: 99; }

设置 visited 样式,当已访问时格子消失:

.visited { display: none; }

在 clickCell 中将点击后的格子设为已访问:

clickCell(row, col) { //if (this.visited[row][col] || this.isGameOver) { // return; //} if (this.isFirstClick) { // this.noMineBlocks = this.colSize * this.rowSize - this.mineSize; this.initMines(row, col); // this.timeStart(); } this.visited[row][col] = true; this.$set(this.visited, row, [...this.visited[row]]); },

可以点几下方格试试,数字立马就出现了。

点击空白格子后的扩散

离扫雷的实现就差最后几步了。而这一步是最重要的,也是最影响游戏体验的。

在扫雷时,一下点开一大块区域的感觉别提有多爽了,而目前的进度点击空白才出现一个格子!这就跟便秘一样难受!所以赶紧来疏通肠道。

首先,在 clickCell 中判断当前的格子是否为 0,若是,开始搜索扩散:

clickCell(row, col) { //if (this.visited[row][col] || this.isGameOver) { // return; //} if (this.isFirstClick) { this.noMineBlocks = this.colSize * this.rowSize - this.mineSize; this.initMines(row, col); this.timeStart(); } this.visited[row][col] = true; this.$set(this.visited, row, [...this.visited[row]]); let cell = this.minesArray[row][col]; if (cell === 0) { // 踩空了 this.search(row, col); } }, search(r, c) { posArr.forEach((pos) => { let x = pos[0] + r; let y = pos[1] + c; if ( x = 0 && y = 0 && !this.visited[x][y] ) { // 若未访问过 this.clickCell(x, y); } }); },

我这里用的是 DFS,纯粹是因为代码简单哈哈哈。这代码已经简单到不用我多说了。这其实就是模拟点击操作,因为 0 的周围 8 格必不是雷,所以看到 0 就把周围 8 个格子点开就完事了。当然了,BFS 应该更加正统一些,毕竟排雷都是一圈一圈排过去的嘛。

扩散

游戏结束

游戏结束有两种方式:1. 踩到地雷 2. 排完所有雷

先从简单的开始。踩到地雷就是当前点击的格子是 -1,触发失败条件:

clickCell(row, col) { if (this.isFirstClick) { //this.noMineBlocks = this.colSize * this.rowSize - this.mineSize; this.initMines(row, col); //this.timeStart(); } this.visited[row][col] = true; this.$set(this.visited, row, [...this.visited[row]]); let cell = this.minesArray[row][col]; if (cell === -1) { // 踩雷了,爆炸 this.fail(); return; } if (cell === 0) { // 踩空了 this.search(row, col); } }, fail() { this.timeStop(); this.isGameOver = true; this.btnContent = "emoji-bad"; this.showMines(false) },

游戏失败

胜利的判断比较复杂,我这里用的是一种比较弱智的思路:算出所有安全格子的数量 noMineBlocks,如果当前的步数 === 安全格子总数,游戏胜利:

clickCell(row, col) { if (this.visited[row][col] || this.isGameOver) { return; } if (this.isFirstClick) { this.noMineBlocks = this.colSize * this.rowSize - this.mineSize; this.initMines(row, col); //this.timeStart(); } this.visited[row][col] = true; this.$set(this.visited, row, [...this.visited[row]]); let cell = this.minesArray[row][col]; if (cell === -1) { // 踩雷了,爆炸 this.fail(); return; } if (--this.noMineBlocks === 0) { // 安全格子全部点完,起飞 this.success(); return; } if (cell === 0) { // 踩空了 this.search(row, col); } },

在该方法开头有个判断条件:

if (this.visited[row][col] || this.isGameOver) { return; }

一个是游戏结束的时候,另一个是已经访问过(即点开数字)的时候。前者是必须要加的,因为我的弱智胜利条件需要这个约束,不然重复点击同一个格子会出大问题——还没点完所有格子就胜利了。

当然,真正胜利的判断条件肯定不会是我这样,这里还需要优化一下子。

对应的胜利方法:

success() { this.timeStop(); this.isGameOver = true; this.btnContent = "emoji-celebrate"; this.showMines(true) },

两个方法都用一个 showMines 方法,为的是在游戏结束时,不管成功与否把所有的地雷显示出来。传入的 boolean 类型表示游戏胜利与否,如果胜利,地雷格子被插上旗子(变绿 ):

showMines(isSuccess) { for (let i = 0; i { time | timeFilter }} { cell }} {{ cell | cellFilter }} ...

然后 cellFilter 判断是否为 0,若是返回 '',否则返回原来的数:

filters: { timeFilter(val) { return Number(val).toFixed(1); }, cellFilter(val) { return val === 0 ? '' : val; } }

但是测试的时候发现,本来是 0 的方格一点变化都没有,就像是未点击一样:

no

实际上是因为 div 是跟着内容变的,返回了一个 '' 值后,div 认为自己没有容纳任何东西,就不出现。解决方法也很简单,只要给这个 div 一个大小就行:

.num-color { width: inherit; height: inherit; /* other code */ }

实际效果:

现在的话扫雷味儿就很足了吧。

关于字体:扫雷的字体太难找啦,既然不影响大局,我就放弃了^^

主界面及计分板样式

主界面也是有阴影的:

.main-area { ... padding: 3px; box-shadow: 3px 3px white inset, -3px -3px #a0a0a0 inset; }

游戏区域与主区域之间要加些空隙:

.game-area { padding: 3px; background-color: #c0c0c0; box-shadow: 3px 3px #a0a0a0 inset, -3px -3px white inset; margin-bottom: 6px; }

在 game-area 上面(同级)插入 top-box,管理按钮、计时器、雷数计数器:

1 {{ time | timeFilter}} ...

设置样式:

.top-box { box-shadow: 2px 2px #a0a0a0 inset, -2px -2px #ffffff inset; padding: 6px; margin-bottom: 6px; margin-top: 6px; text-align: center; } .top-box-item { display: inline-block; } .remain-mines { float: left; } .time { float: right; } .show-number { width: 41px; height: 25px; box-shadow: -1px -1px white inset, 1px 1px #808080 inset; }

设置按钮样式:

.restart-btn { width: 25px; height: 25px; border: 0px; background: #c0c0c0; border-left: 1px solid black; border-top: 1px solid black; border-right: 1px solid #808080; border-bottom: 1px solid #808080; box-shadow: -1px -1px #808080 inset, 2px 2px white inset, -2px -2px #808080 inset; } .restart-btn:active { box-shadow: 2px 2px #808080 inset; } .restart-btn:touch { box-shadow: 2px 2px #808080 inset; } .restart-btn:focus { outline: none; }

效果如下:

效果

向按钮中添加表情

从 iconfont 中找几个有代表性的表情放到项目中:

iconfont表情

引入 js 文件:

插入到 中:

调整样式:

.icon { width: 1.3em; height: 1.3em; vertical-align: -0.15em; fill: currentColor; overflow: hidden; transform: translatex(-3px); }

为了实现表情的变化,xlink:href 的值与 emojiType 息息相关。可以根据目前的状态,将 emojiType 设置成 default, click, fail, success,就能在变化时自动从库中引用 icon-emoji-xxx 对应的表情:

data: { ... emojiType : 'default' }, methods: { onRestartBtnClick() { // 将 restart 单独提了出来,按钮绑定的是这个 this.emojiType = "default"; this.restart(); }, fail() { this.emojiType = 'fail' ... }, success() { this.emojiType = 'success' ... } ... }

要在按下时改变表情,需添加 @mousedown 事件监听,以及在 @mouseup 时复原:

... methods: { mousedown() { this.emojiType = "click" }, mouseup() { this.emojiType = "default" } } 液晶字体

终于找到字体啦,用的是 FX-LED.TTF 液晶字体,导入到 css 中,再设置相应的样式就行:

{{remainMines}} {{ time | timeFilter}} @font-face { font-family: 'fxled'; src: url('./font/FX-LED.TTF'); } .num-box { font-family: fxled; font-size: 30px; color: red; text-align: right; line-height: inherit; padding: 0 2px; background: black; }

这。。勉强够用哈

代码优化

随着我们需求的不断增加,存放状态的二维数组将会越来越多(minesArray, visited flag…)。显然,同时维护这么多数组不是一个好事,不仅维护起来非常麻烦(牵一发而动全身),代码量也会成倍增长,更别说什么逻辑请不清晰,易不易读了。基于面向对象编程的思想,我们可以把这些状态放在一个类中进行统一管理。

首先创建一个 Cell 类,这个类是每个小格子的抽象形式,存放的是当前格子的各种状态:坐标、是否被访问、实际的数值、是否被标记等等:

class Cell { constructor(row, col) { this.row = row; this.col = col; this.isVisited = false; this.isFlagged = false; this.cell = ""; this.val = 0; this.neighbors = []; } }

this.neighbors 存放当前小格子的周围 8 个”邻居“,能减少重复的计算。

然后创建一个二维数组,专门管理这些 Cells:

data: { ... this.cellMatrix : '' } methods: { initGame() { this.isFirstClick = true; this.isGameOver = false; if (this.timer) { this.timeStop(); } this.timer = ""; this.time = 0.0; this.remainMines = this.mineSize; // 初始化二维数组 this.cellMatrix = []; for (let row = 0; row { posArr.forEach((p) => { let x = p[0] + cell.row; let y = p[1] + cell.col; if (x >= 0 && x = 0 && y {{ cell.cell }}

这样每个格子都由这个 cell 进行管理,实际上已经脱离了二维数组 cellMatrix,cell 中属性的改变可以被监听到,跳出了那个巨坑,不再需要 this.$set() 了。

2020.11.13 指正: 能被监听到的原因与二维数组的初始化中的 push 有关。

for (let row = 0; row { if (isSuccess) { //cell.isFlagged = true; cell.val = -2; } else { //if (!cell.isFlagged) cell.isVisited = true; cell.isVisited = true; } }); }, initGame() { this.isFirstClick = true; this.isGameOver = false; if (this.timer) { this.timeStop(); } this.timer = ""; this.time = 0.0; this.remainMines = this.mineSize; this.cellMatrix = []; for (let row = 0; row { posArr.forEach((p) => { let x = p[0] + cell.row; let y = p[1] + cell.col; if (x >= 0 && x = 0 && y { if (cell.val === -1) { cell.neighbors.forEach((neighbor) => { if (neighbor.val !== -1) { neighbor.val++; } }); } }); }); }, },

经过这么一番修改,代码少了很多,看着也不太累了。最主要的是,因为有了 neighbors 的存在,在【困难】难度下点格子的速度快了很多,终于不用卡了呜呜呜(不过第一次点击还是会卡那么一会)。

功能优化 (伪)地雷实装

这个讲道理应该放在界面优化的,不过按照时间顺序来说,这是在优化了代码之后才做的事,所以索性就扔这里了。

我们地雷终于要有新面貌了!终于不再是数字了!

首先,还是去 iconfont 上找个符合意境的图标,小小的编辑一下大小、名字等等(别忘了更新 js 哦):

boom-icon

把代码扔到 html 里,使用 v-if 控制其出现的位置:



【本文地址】


今日新闻


推荐新闻


CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3