更新记录
1.0.0(2024-10-23)
下载此版本
无
平台兼容性
App |
快应用 |
微信小程序 |
支付宝小程序 |
百度小程序 |
字节小程序 |
QQ小程序 |
× |
× |
√ |
× |
× |
× |
× |
钉钉小程序 |
快手小程序 |
飞书小程序 |
京东小程序 |
× |
× |
× |
× |
H5-Safari |
Android Browser |
微信浏览器(Android) |
QQ浏览器(Android) |
Chrome |
IE |
Edge |
Firefox |
PC-Safari |
× |
√ |
√ |
√ |
√ |
× |
× |
√ |
√ |
1. 效果图
2. 游戏规则
扫雷的规则很简单。盘面上有许多方格,方格中随机分布着一些雷。你的目标是避开雷,打开其他所有格子。一个非雷格中的数字表示其相邻 8 格子中的雷数,你可以利用这个信息推导出安全格和雷的位置。你可以用右键在你认为是雷的地方插旗(称为标雷)。你可以用左键打开安全的地方,左键打开雷将被判定为失败。
3. 实现思路
- 创建 row 行 col 列的二维数组,注意行和列创建时都要生成他的唯一 key;
- 根据选择的难度,将对应难度的雷的个数随机埋入 row * col 个格子中;
- 统计每个格子周边八个格子中雷的个数;
- 每个格子的状态:bomb 是否是存在雷的格子,bombCount 当前格子周边格子的雷的数量,flag 当前格子是否被插旗,opened 当前格子是否被翻开;
- 根据每个格子的状态,渲染对应的图片;
- 点击每个格子,执行对应的操作,比如插旗,翻开,是雷就爆炸等。
4. 创建背景
4.1 HTML 结构
<view class="rui-flex-cc">
<view class="rui-minesweeper-content">
<view class="rui-minesweeper-header-content">
<view class="rui-header-counter">{{ bombCount.toString().padStart(3, '0') }}</view>
<view class="rui-header-btn" @click="start">
<view v-if="isGameOver">
<image v-if="isSuccess" :src="icon.iconFaceSuccess" class="rui-btn-icon"></image>
<image v-else :src="icon.iconFaceFail" class="rui-btn-icon"></image>
</view>
<image v-else :src="icon.iconFaceNormal" class="rui-btn-icon"></image>
</view>
<view class="rui-header-counter">{{ sec.toString().padStart(3, '0') }}</view>
</view>
<!-- 雷区 -->
<view class="rui-main-content">
</view>
</view>
</view>
4.2 样式
$primary: #C0C0C0;
$light: #EEEEEE;
$dark: #969696;
.rui-minesweeper-content{
background-color: $primary;
width: 250px;
height: 300px;
margin: 20px auto;
padding: 8px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: default;
user-select: none;
.rui-minesweeper-header-content{
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
border: 3px inset $light;
box-sizing: border-box;
padding: 5px 10px;
.rui-header-counter {
background-color: black;
color: red;
font-family: Impact;
min-width: 2em;
text-align: center;
}
.rui-header-btn {
border: 2px outset #eee;
width: 25px;
height: 25px;
display: flex;
align-items: center;
justify-content: center;
.rui-btn-icon{
width: 21px;
height: 21px;
border-radius: 50%;
display: block;
}
}
}
}
4.3 实现结果
5. 初始化格子
5.1 代码分析
- 获取当前难度等级:通过 find 方法从 navbars 数组中找到 ischeck 属性为 true 的第一个元素,即当前选中的难度等级。
- 提取难度等级的属性:使用解构赋值从 curGrade 对象中提取出 bombCount、row 和 col 属性,分别代表地雷数量、行数和列数。
- 初始化游戏状态:初始化游戏的各种状态变量,包括计时器(sec)、游戏结束标志(isGameOver)、游戏成功标志(isSuccess)、最大行数(maxrow)、最大列数(maxcol)、地雷数量(bombCount)和空白格子数量(blankCount)。
- 初始化网格:调用 initCells 方法来初始化游戏网格,该方法根据传入的行数和列数创建一个二维数组,用于表示游戏的网格。
- 随机放置地雷:调用 fixUpMinesweepers 方法在游戏网格中随机放置地雷。
- 计算当前位置周边雷的个数:调用 findCurrentPointAroundMinesweeperCount 方法来计算每个格子周围的地雷数量,这个信息通常用于在游戏中显示给玩家。
5.2 初始化代码实现
// 开始重置
start(){
let curGrade = this.navbars.find(item => item.ischeck);
let { bombCount, row, col } = curGrade;
this.sec = 0;
this.isGameOver = false;
this.isSuccess = false;
this.maxrow = row;
this.maxcol = col;
this.bombCount = bombCount;
this.blankCount = row * col - bombCount;
// 初始化网格
this.initCells(row, col);
// 随机放置地雷
this.fixUpMinesweepers();
// 计算当前位置周边雷的个数
this.findCurrentPointAroundMinesweeperCount();
}
5.3 初始化网格
- 初始化外层数组:使用 Array.from 方法创建一个长度为 row 的新数组,并使用一个映射函数来初始化每个元素。映射函数的参数 r 代表当前正在处理的行,而 ridx 代表该行的索引。返回一个对象,该对象包含两个属性:
- keyId: 一个随机生成的字符串,用于标识该行。
- list: 一个新的数组,使用 Array.from 方法创建,长度为 col。
- 初始化内层数组:数组的每个元素也是一个对象,包含以下属性:
- bomb: 一个布尔值,表示该单元格是否包含地雷,初始值为 false。
- bombCount: 一个数字,表示该单元格周围的地雷数量,初始值为 0。
- flag: 一个布尔值,表示该单元格是否被标记为地雷,初始值为 false。
- opened: 一个布尔值,表示该单元格是否被翻开,初始值为 false。
5.4 网格代码实现
// 初始化网格
initCells(row, col){
this.cells = Array.from({ length: row }, (r, ridx) => {
return {
keyId: `id-${randomString()}`,
list: Array.from({length: col}, (c, cidx) => {
return {
keyId: `id-${randomString()}`,
bomb: false,
bombCount: 0,
flag: false,
opened: false
}
})
}
})
}
6. 随机放置地雷
6.1 代码分析
- 初始化:初始化一个局部变量 cells,它引用了组件实例的 cells 属性,即二维数组,表示游戏网格。
- 放置地雷的循环:使用 for 循环,它将执行 this.bombCount 次,即要放置的地雷数量。在每次循环中,它生成两个随机数 row 和 col,分别代表地雷在棋盘上的行和列索引。
- 检查并放置地雷:检查生成的随机位置 cells[row].list[col] 是否已经包含了地雷。如果没有,它将该位置标记为地雷(bomb 属性设置为 true)。如果该位置已经有地雷,它将 i 减一,以便在下一次循环中重新尝试放置地雷。
6.2 代码实现
fixUpMinesweepers(){
let cells = this.cells;
for(let i = 0; i < this.bombCount; i++){
let row = Math.floor(Math.random() * this.maxrow);
let col = Math.floor(Math.random() * this.maxcol);
if(!cells[row].list[col].bomb){
cells[row].list[col].bomb = true;
} else {
i--;
}
}
}
7. 计算当前位置周边雷的个数
7.1 代码分析
- 初始化:初始化一个局部变量 cells,它引用了组件实例的 cells 属性,即二维数组,表示游戏棋盘。
- 两层嵌套循环遍历单元格:使用两层嵌套循环遍历游戏棋盘上的每个单元格。外层循环遍历每一行,内层循环遍历每一列。对于每个单元格,它调用 handleAroundPoints 函数来处理该单元格周围的点。
- 调用 handleAroundPoints 函数:
- cells:二维数组,表示游戏棋盘。
- row:当前单元格的行索引。
- col:当前单元格的列索引。
- maxrow:游戏棋盘的最大行数。
- maxcol:游戏棋盘的最大列数。
- callback:一个回调函数,用于对每个相邻单元格执行操作。在这个回调函数中,它将当前单元格的 bombCount 属性增加 1,如果相邻单元格是地雷;否则,不增加。
7.2 代码实现
findCurrentPointAroundMinesweeperCount(){
let cells = this.cells;
for(let row = 0; row < this.maxrow; row++) {
for(let col = 0; col < this.maxcol; col++) {
handleAroundPoints({
cells,
row,
col,
maxrow: this.maxrow,
maxcol: this.maxcol,
callback: cCell => cells[row].list[col].bombCount += cCell.bomb ? 1 : 0
})
}
}
}
7.3 遍历当前位置周边代码分析
- 函数的参数包括:
- cells:一个二维数组,表示单元格的网格。
- row:当前单元格的行索引。
- col:当前单元格的列索引。
- maxrow:网格的最大行数。
- maxcol:网格的最大列数。
- callback:一个回调函数,用于对每个相邻单元格执行操作。
- 函数内部使用了两个嵌套的 for 循环来遍历相邻单元格。外层循环遍历行,内层循环遍历列。通过 Math.max 和 Math.min 函数确保循环的边界不会超出网格的范围。同时,使用 continue 语句跳过当前单元格本身,只处理相邻单元格。
- 通过回调函数 callback 来对每个相邻单元格执行操作,传入的参数包括单元格的值、行索引和列索引。
7.4 遍历当前位置周边代码实现
function handleAroundPoints({cells, row, col, maxrow, maxcol, callback}) {
for(let srow = Math.max(row - 1, 0); srow < Math.min(row + 2, maxrow); srow++) {
for(let scol = Math.max(col - 1, 0); scol < Math.min(col + 2, maxcol); scol++) {
if (srow === row && scol === col) continue;
callback(cells[srow].list[scol], srow, scol)
}
}
}
7.5 实现结果
8. 点击事件
8.1 逻辑分析
- 初始化:接受两个参数:row 和 col,分别代表点击的单元格的行和列索引。方法内部首先检查是否存在一个计时器(timer),如果不存在,则创建一个计时器,每1000毫秒(即1秒)调用一次 onTick 方法。
- 点击事件:当前选中的点击类型(curTypeItem.type)是0,即左键【翻开】点击,代码会检查点击的单元格是否被标记为旗子(flag)。如果是,则不做任何操作;如果不是,它会进一步检查单元格是否包含地雷(bomb)。如果是地雷,单元格将被标记为已打开(opened),并且调用 onExplode 方法表示游戏失败;如果不是地雷,它将调用 onOpen 方法来打开单元格。
- 插旗点击事件:当前选中的点击类型是1,即右键【插旗】点击,代码将调用 onFlag 方法来标记或取消标记单元格为旗子。
8.2 代码实现
onClick(row, col) {
let curTypeItem = this.chooseClickTypes.find(item => item.ischeck);
if(!this.timer){
this.timer = setInterval(this.onTick, 1000);
}
if(curTypeItem.type == 0){
const cell = this.cells[row].list[col];
if (cell.flag){
return false;
}
if(cell.bomb){
cell.opened = true;
this.onExplode();
} else {
this.onOpen(row, col);
}
} else if(curTypeItem.type == 1){
this.onFlag(row, col);
}
}
9. 翻开逻辑
9.1 逻辑分析
- 初始化:接受两个参数:row 和 col,分别代表点击的单元格的行和列索引。方法内部首先获取当前单元格的引用,然后将其标记为已打开(opened),并清除其标记为旗子的状态(flag)。接着,它减少空白单元格计数器(blankCount)的值,表示已经打开了一个单元格。
- 检查游戏胜利条件:检查 blankCount 是否小于1,如果是,则表示所有非地雷单元格都已被打开,游戏胜利,因此调用 onSuccess 方法。
- 检查是否需要开启周围单元格:当前单元格的地雷数量(bombCount)为0,则表示其周围没有地雷,需要开启周围的单元格。因此,调用 openAround 方法来开启当前单元格周围的单元格。
9.2 代码实现
// 开启当前位置
onOpen(row, col) {
const cell = this.cells[row].list[col];
cell.opened = true;
cell.flag = false;
this.blankCount--;
if (this.blankCount < 1) {
this.onSuccess()
} else if (cell.bombCount === 0) {
this.openAround(row, col)
}
}
10. 翻开当前位置的周边没有雷区和没有开启的位置
10.1 逻辑分析
- 获取了当前的单元格数组 cells,然后调用了一个名为 handleAroundPoints 的函数。
- 函数接收一个对象作为参数,该对象包含以下属性:
- cells:当前的单元格数组。
- row:当前单元格的行索引。
- maxrow:网格的最大行数。
- maxcol:网格的最大列数。
- callback:一个回调函数,该函数接收三个参数:cCell(当前处理的单元格),crow(当前处理的单元格的行索引),ccol(当前处理的单元格的列索引)。回调函数的作用是检查当前单元格是否满足翻开条件,如果满足,则翻开该单元格。
- 检查当前单元格是否已经被打开、是否是炸弹或者是否被标记为旗子。如果这些条件都不满足,则调用 this.onOpen(crow, ccol) 方法来打开当前单元格。
10.2 代码实现
// 开启当前位置的周边没有雷区和没有开启的位置
openAround(row, col) {
let cells = this.cells;
handleAroundPoints({
cells,
row,
col,
maxrow: this.maxrow,
maxcol: this.maxcol,
callback: (cCell, crow, ccol) => {
if (!cCell.opened && !cCell.bomb && !cCell.flag){
this.onOpen(crow, ccol);
}
}
})
}
11. 增加难度选择
11.1 代码实现
// 切换难度
changeGrade(idx){
let navbars = this.navbars;
navbars.forEach(item => item.ischeck = false);
navbars[idx].ischeck = true;
this.start();
}
11.2 实现效果
11.2.1 初级
11.2.1 中级
12. 翻开或插旗切换
12.1 代码实现
// 切换点击事件类型
changeClickType(idx){
let chooseClickTypes = this.chooseClickTypes;
chooseClickTypes.forEach(item => item.ischeck = false);
chooseClickTypes[idx].ischeck = true;
}
12.2 实现效果
13. 全部代码
<template>
<view>
<view class="rui-flex-cc">
<view class="rui-flex-ac">
<view :class="{
'rui-navbar-li': true,
'rui-active': nav.ischeck
}"
@click="changeGrade(idx)"
v-for="(nav,idx) in navbars" :key="nav.keyId">
{{nav.name}}
</view>
</view>
</view>
<view class="rui-flex-cc">
<view class="rui-minesweeper-content">
<view class="rui-minesweeper-header-content">
<view class="rui-header-counter">{{ bombCount.toString().padStart(3, '0') }}</view>
<view class="rui-header-btn" @click="start">
<view v-if="isGameOver">
<image v-if="isSuccess" :src="icon.iconFaceSuccess" class="rui-btn-icon"></image>
<image v-else :src="icon.iconFaceFail" class="rui-btn-icon"></image>
</view>
<image v-else :src="icon.iconFaceNormal" class="rui-btn-icon"></image>
</view>
<view class="rui-header-counter">{{ sec.toString().padStart(3, '0') }}</view>
</view>
<!-- 雷区 -->
<view class="rui-main-content">
<view class="rui-row" v-for="(row,ridx) in cells" :key="row.keyId">
<view class="rui-col" v-for="(col,cidx) in row.list"
@click="onClick(ridx, cidx)"
@contextmenu.prevent="onFlag(ridx, cidx)"
:key="col.keyId">
<view v-if="isGameOver">
<image v-if="!col.bomb" :src="icon[`icon${col.bombCount}`]" class="rui-icon"></image>
<view v-else>
<image v-if="col.opened" :src="icon.iconBlood" class="rui-icon"></image>
<image v-else :src="icon.iconMine" class="rui-icon"></image>
</view>
</view>
<view v-else>
<image v-if="col.flag" :src="icon.iconFlag" class="rui-icon"></image>
<image v-else-if="col.opened" :src="icon[`icon${col.bombCount}`]" class="rui-icon"></image>
<image v-else-if="!col.opened" :src="icon.iconBlank" class="rui-icon"></image>
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 切换点击事件类型 -->
<view class="rui-flex-cc">
<view class="rui-flex-ac">
<view :class="{
'rui-navbar-li': true,
'rui-active': typeItem.ischeck
}"
@click="changeClickType(idx)"
v-for="(typeItem,idx) in chooseClickTypes" :key="typeItem.keyId">
{{typeItem.name}}
</view>
</view>
</view>
</view>
</template>
<script>
import icon from './icon';
import { randomString, handleAroundPoints } from './utils';
export default {
name:"RuiMinesweeper",
data() {
return {
icon,
sec: 0,
bombCount: 0,
blankCount: 0,
maxrow: 0,
maxcol: 0,
isGameOver: false,
isSuccess: false,
cells: [],
chooseClickTypes: [{
name: '翻开',
type: 0,
keyId: `id-${randomString()}`,
ischeck: true
},{
name: '插旗',
type: 1,
keyId: `id-${randomString()}`,
ischeck: false
}],
navbars: [{
name: '初级',
row: 9,
col: 9,
bombCount: 10,
keyId: `id-${randomString()}`,
ischeck: true
},{
name: '中级',
row: 16,
col: 16,
bombCount: 40,
keyId: `id-${randomString()}`,
ischeck: false
},{
name: '高级',
row: 20,
col: 20,
bombCount: 99,
keyId: `id-${randomString()}`,
ischeck: false
},{
name: '自定义',
row: 30,
col: 30,
bombCount: 150,
keyId: `id-${randomString()}`,
ischeck: false
}],
};
},
created(){
this.start()
},
methods: {
// 切换难度
changeGrade(idx){
let navbars = this.navbars;
navbars.forEach(item => item.ischeck = false);
navbars[idx].ischeck = true;
this.start();
},
// 切换点击事件类型
changeClickType(idx){
let chooseClickTypes = this.chooseClickTypes;
chooseClickTypes.forEach(item => item.ischeck = false);
chooseClickTypes[idx].ischeck = true;
},
// 开始重置
start(){
let curGrade = this.navbars.find(item => item.ischeck);
let { bombCount, row, col } = curGrade;
this.sec = 0;
this.isGameOver = false;
this.isSuccess = false;
this.maxrow = row;
this.maxcol = col;
this.bombCount = bombCount;
this.blankCount = row * col - bombCount;
// 初始化网格
this.initCells(row, col);
// 随机放置地雷
this.fixUpMinesweepers();
// 计算当前位置周边雷的个数
this.findCurrentPointAroundMinesweeperCount();
},
// 初始化网格
initCells(row, col){
this.cells = Array.from({ length: row }, (r, ridx) => {
return {
keyId: `id-${randomString()}`,
list: Array.from({length: col}, (c, cidx) => {
return {
keyId: `id-${randomString()}`,
bomb: false,
bombCount: 0,
flag: false,
opened: false
}
})
}
})
},
// 随机放置地雷
fixUpMinesweepers(){
let cells = this.cells;
for(let i = 0; i < this.bombCount; i++){
let row = Math.floor(Math.random() * this.maxrow);
let col = Math.floor(Math.random() * this.maxcol);
if(!cells[row].list[col].bomb){
cells[row].list[col].bomb = true;
} else {
i--;
}
}
},
// 计算当前位置周边雷的个数
findCurrentPointAroundMinesweeperCount(){
let cells = this.cells;
for(let row = 0; row < this.maxrow; row++) {
for(let col = 0; col < this.maxcol; col++) {
handleAroundPoints({
cells,
row,
col,
maxrow: this.maxrow,
maxcol: this.maxcol,
callback: cCell => cells[row].list[col].bombCount += cCell.bomb ? 1 : 0
})
}
}
},
// 计时器一千秒
onTick() {
if (this.sec < 999){
this.sec++;
} else {
// 时间完成,结束游戏
this.onStop();
}
},
// 结束游戏
onStop() {
this.isGameOver = true;
if(this.timer) {
clearInterval(this.timer);
this.timer = null;
}
},
// 爆炸
onExplode(){
this.isSuccess = false;
this.onStop();
},
// 成功
onSuccess(){
this.isSuccess = true;
this.onStop();
},
// 插旗操作
onFlag(row, col){
const cell = this.cells[row].list[col];
cell.flag = !cell.flag
cell.flag ? this.bombCount-- : this.bombCount++;
},
// 点击事件
onClick(row, col) {
let curTypeItem = this.chooseClickTypes.find(item => item.ischeck);
if(!this.timer){
this.timer = setInterval(this.onTick, 1000);
}
if(curTypeItem.type == 0){
const cell = this.cells[row].list[col];
if (cell.flag){
return false;
}
if(cell.bomb){
cell.opened = true;
this.onExplode();
} else {
this.onOpen(row, col);
}
} else if(curTypeItem.type == 1){
this.onFlag(row, col);
}
},
// 开启当前位置
onOpen(row, col) {
const cell = this.cells[row].list[col];
cell.opened = true;
cell.flag = false;
this.blankCount--;
if (this.blankCount < 1) {
this.onSuccess()
} else if (cell.bombCount === 0) {
this.openAround(row, col)
}
},
// 开启当前位置的周边没有雷区和没有开启的位置
openAround(row, col) {
let cells = this.cells;
handleAroundPoints({
cells,
row,
col,
maxrow: this.maxrow,
maxcol: this.maxcol,
callback: (cCell, crow, ccol) => {
if (!cCell.opened && !cCell.bomb && !cCell.flag){
this.onOpen(crow, ccol);
}
}
})
}
}
}
</script>
<style lang="scss" scoped>
$primary: #C0C0C0;
$light: #EEEEEE;
$dark: #969696;
.rui-flex-cc{
display: flex;
justify-content: center;
align-items: center;
}
.rui-flex-ac{
display: flex;
align-items: center;
}
.rui-icon{
width: 25px;
height: 25px;
display: block;
}
.rui-navbar-li{
color: #23527c;
margin: 20px;
}
.rui-navbar-li.rui-active{
color: #444;
font-weight: 700;
}
.rui-minesweeper-content{
background-color: $primary;
margin: 20px auto;
padding: 8px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: default;
user-select: none;
.rui-minesweeper-header-content{
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
border: 3px inset $light;
box-sizing: border-box;
padding: 5px 10px;
.rui-header-counter {
background-color: black;
color: red;
font-family: Impact;
min-width: 2em;
text-align: center;
}
.rui-header-btn {
border: 2px outset #eee;
width: 25px;
height: 25px;
display: flex;
align-items: center;
justify-content: center;
.rui-btn-icon{
width: 21px;
height: 21px;
border-radius: 50%;
display: block;
}
}
}
.rui-main-content{
width: 100%;
height: 100%;
border: 3px inset #eee;
box-sizing: border-box;
}
.rui-row{
display: flex;
align-items: center;
}
.rui-col {
flex: none;
width: 25px;
height: 25px;
display: flex;
align-items: center;
justify-content: center;
}
.rui-col.no-event {
pointer-events: none;
}
}
</style>
14. 工具函数
export function randomString(e) {
e = e || 32;
const t = "abcdefghijklmnopqrstwxyz1234567890";
const a = t.length;
let n = "";
for (let i = 0; i < e; i++) {
n += t.charAt(Math.floor(Math.random() * a));
}
return n;
}
export function handleAroundPoints({cells, row, col, maxrow, maxcol, callback}) {
for(let srow = Math.max(row - 1, 0); srow < Math.min(row + 2, maxrow); srow++) {
for(let scol = Math.max(col - 1, 0); scol < Math.min(col + 2, maxcol); scol++) {
if (srow === row && scol === col) continue;
callback(cells[srow].list[scol], srow, scol)
}
}
}
15. 总结
- 逻辑实现其实不是很难,最主要的是需要理清逻辑,然后按部就班的实现就可以了,写代码实现很重要,不要永远在思考的层面。
- 由于这个是按照 PC 实现的界面,所以在移动端游戏界面会超出视图,因此可以在此基础,将难度在一个页面选择,然后动态计算每个难度格子的大小。
- 基本的逻辑已完善,如果需要引流等代码,需要对饮的自己添加,由于使用的都是图片,换UI界面也比较容易,设计一套新的UI,替换图片就可以。