Featured image of post 使用Vue3+VueRouter写一个扫雷

使用Vue3+VueRouter写一个扫雷

有趣的很

成品 demo:MineSweeper

GitHub:Xav1erSue/mineSweeper

最近没啥项目好写,突发奇想,能不能写点小游戏出来?于是萌生了做个扫雷的念头。js 写游戏看似简单,实际上还是有点小难度的,对于清晰的应用逻辑有比较高的要求,在实际编写中也遇到了许多问题。

首先来分析一下需求:

需要实现的功能

页面路由:

  • 首页为 Home 页,在 Home 页中进行难度选择。
  • 三种难度的游戏页面:Easy、Middle、Hard(可选)

游戏窗口:

  • N×N 的方格矩阵
  • 每个格子可以进行左键点击右键标记
  • 已经被标记或者被打开的格子不能进行点击操作
  • 点到地雷 GameOver
  • 排出所有雷后胜利

基本功能如上,下面具体分析下具体的实现逻辑

实现逻辑

路由配置

应用使用Vue3+VueRouter构建,路由采用History模式,主要是不想看到那个"#“号,构建项目时选择 History 模式的 VueRouter 就行,Vue-CLI 的集成度还是很高的,基本上不需要对 webpack 进行太多配置。关于HistoryHash模式的不同以及在构建和部署时的操作会在后续文章中阐释,这里不多赘述,详见:《History 和 Hash 路由有什么区别》

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// router/index.js配置
...
const routes = [
  {
    path: "/",
    name: "Home",
    component: () => import("../views/home.vue"),
  },
  {
    path: "/easy",
    name: "Easy",
    component: () => import("../views/easy.vue"),
  },
  {
    path: "/middle",
    name: "Middle",
    component: () => import("../views/middle.vue"),
  },
  {
    path: "/hard",
    name: "Hard",
    component: () => import("../views/hard.vue"),
  },
];
...
1
2
3
4
5
6
7
8
9
<!-- App.vue模板 -->
<template>
  <div id="container">
    <header>MineSweeper</header>
    <hr />
    <router-view></router-view>
  </div>
  <footer>© 2021 Xav1erSue,All rights reserved.</footer>
</template>
1
2
3
4
5
6
7
8
9
<!-- Home.vue模板 -->
<template>
  <div class="motd">choose A model to play</div>
  <div id="model">
    <router-link class="btn" to="easy">Easy</router-link>
    <router-link class="btn" to="middle">Middle</router-link>
    <router-link class="btn" to="hard">Hard</router-link>
  </div>
</template>

即可实现三种难度页面的路由切换。

由于没有对组件进行复用处理,所以三种难度的代码存在冗余,会在后续的更新中进行复用处理。

生成游戏窗口

添加全局变量blocks,以二维数组形式储存格子信息,使用 v-for 对blocksY轴方向遍历

1
<div class="columns" v-for="(itemY, Yindex) in blocks" :key="Yindex"></div>

再在每一层继续对X轴进行遍历,核心代码如下,其他参数之后设定

1
2
3
<div class="columns" v-for="(itemY, Yindex) in blocks" :key="Yindex">
  <div class="rowBlocks" v-for="(itemX, Xindex) in itemY" :key="Xindex"></div>
</div>

配置class之后就可以生成一个拥有N×N个格子的窗口。但只有当blocks不为空时才能生成,所以在挂载之前需要对blocks进行赋初值,这里涉及到对二维数组的赋值,数组的引用类型会出现一点小 bug,可以参考我的上一篇文章:《二维数组应该怎么赋初值》,这里直接给出代码,在 src 目录下新建utils文件夹存放全局方法。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// src/utils/setArray.js
// 初始化数组
function setEmptyArray(n) {
  let arr = [];
  for (let i = 0; i < n; i++) {
    arr[i] = new Array(n).fill(0);
  }
  for (let i = 0; i < n; i++) {
    for (let j = 0; j < n; j++) {
      arr[i][j] = {
        tempFlag: 0, // 下面遍历时用到的临时标记变量
        minesNearbBy: 0, // 记录该方块周围八个格子的地雷数
        isMine: 0, // 记录该方块是否是地雷
        marked: 0, // 记录该方块是否被标记
        clickAble: 1, // 记录方块是否可以点击
        shown: 0, // 记录方块是否被边缘显露
      };
    }
  }
  return arr;
}

// import { setEmptyArray } from "../utils/setArray";

扫雷的玩法是:点开格子后,显示出当前格子周围一格距离(周边 9×9 方格中剩下 8 个格子)内地雷的个数,记为minesNearBy,并通过这个数字判断未打开的格子中是否含有地雷,并且将其标记出来,打开所有非地雷格子从而获胜,所以每一个格子都有自己的独立信息,在上述代码中对格子的初始值进行设定。下文会对这些变量进行解释。

初始值设定后,v-for 的两重嵌套就可以渲染出 N×N 个 div 格子了。接下来对每个格子的点击事件进行相应的设定

左键点击事件:打开格子

1. 第一次左键点击时开始游戏:

  • 第一次左键点击时不能点到地雷

  • 第一次左键点击时标志游戏开始 // .start = 1

  • 把这个格子标记为不可点击 // .clickAble = 0

2. 左键点击时对该格进行判定,根据判定结果对应不同操作

  • 只有游戏开始了才能进行左键点击

  • 只有标记为可点击的格子才可以左键点击

  • 点到了地雷就 GG

  • 若该格minesNearBy值为 0,进行连锁打开;不为 0,则只打开当前格子

  • 把这个格子标记为不可点击 // .clickAble = 0

  • 若点开后剩下的格子数等于地雷数,则判定游戏获胜

右键点击事件:标记格子

1. 右键点击时对该格进行判定:

  • 只有游戏开始了才能进行右键点击

  • 只有标记为可点击的格子才可以右键点击

  • 若该格没有被标记,则把该格设定为已标记 // .marked = 1

  • 若该格已被标记,则把该格设定为未标记 // .marked = 0

  • 记录已被标记的格子数 // .marks++ || .marks--

  • 把这个格子标记为不可点击 // .clickAble = 0

基本逻辑差不多就是这些,对应的方法如下,以 Easy 难度为例:

代码实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 左键点击调用方法
open(x, y) {
      // 1. 第一次点开:生成地雷
      if (this.start == 0 && this.gg == 0) {
        this.setMines(x, y);
        this.seenMotd = false;
        this.start = 1;
        // 当前格子一定没有被点击过
        this.chainOpen(x, y);
        return;
      }
      // 只有游戏处于开始状态才能进行点击操作
      if (this.start == 1) {
        // 2. 点到没有被标记的地雷:GG
        if (this.blocks[x][y].isMine == 1 && this.blocks[x][y].clickAble == 1) {
          this.blocks[x][y].styles = this.styles[2];
          this.gg = 1;
          this.start = 0;
          alert("GAME OVER");
        }
        // 点到空白
        if (this.blocks[x][y].isMine != 1 && this.blocks[x][y].clickAble == 1) {
          this.chainOpen(x, y);
          this.clickTimes++;
        }
        if (this.blocksLeft == 10) {
          this.gg = 1;
          this.start = 0;
          alert("you win!");
        }
      }
    },
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 右键点击调用方法
mark(x, y) {
      event.preventDefault(); // 取消默认事件
      // 只有游戏开始了才能标记地雷
      if (this.start == 1) {
        if (
          this.blocks[x][y].marked == 0 &&
          this.marks < 10 &&
          this.blocks[x][y].clickAble == 1
        ) {
          // 没有被标记,标记数量少于10:标记该方块
          this.blocks[x][y].marked = 1;
          this.blocks[x][y].clickAble = 0;
          this.blocks[x][y].styles = this.styles[1];
          this.marks++;
          this.clickTimes++;
        } else if (this.blocks[x][y].marked == 1) {
          // 被标记:取消标记
          this.blocks[x][y].marked = 0;
          this.blocks[x][y].clickAble = 1;
          this.blocks[x][y].styles = {};
          this.marks--;
          this.clickTimes++;
        }
      }
    },

utils/setArray.js里面封装了其他方法,如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// 输入矩阵阶数n,mines个地雷和初始坐标x0,y0
function setRandomMines(n, mines, x0, y0) {
  // 创建初始值为空的n*n矩阵数组
  let arr = [];
  for (let i = 0; i < n; i++) {
    arr[i] = new Array(n).fill(0);
  }
  while (mines > 0) {
    let x = Math.floor(Math.random() * n);
    let y = Math.floor(Math.random() * n);
    // 当前位置没有被设置过且不是第一次点击的坐标是创建地雷
    if (arr[x][y] != 1 && x != x0 && y != y0) {
      arr[x][y] = 1;
      mines--;
    }
  }
  return arr;
}

// 计算周围地雷数
function computeNearBy(arr, x, y, n) {
  // 周围最多存在八个格子
  let directions = [
    [-1, -1],
    [-1, 0],
    [-1, 1],
    [0, -1],
    [0, 1],
    [1, -1],
    [1, 0],
    [1, 1],
  ];
  let sum = 0;
  for (let i = 0; i < 8; i++) {
    let tx = x + directions[i][0];
    let ty = y + directions[i][1];
    if (tx < 0 || tx >= n || ty < 0 || ty >= n) continue;
    if (arr[tx][ty].isMine == 1) sum++;
  }
  return sum;
}

以及在opne()mark()两个方法内调用的其他方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
setMines(x, y) {
      // nxn的方格定义10个雷
      let minesArr = setRandomMines(this.n, 10, x, y);
      for (let i = 0; i < this.n; i++) {
        for (let j = 0; j < this.n; j++) {
          this.blocks[i][j].isMine = minesArr[i][j];
          if (minesArr[i][j] == 1) {
            this.blocks[i][j].styles = { color: "red" };
          }
        }
      }
      for (let i = 0; i < this.n; i++) {
        for (let j = 0; j < this.n; j++) {
          if (this.blocks[i][j].isMine != 1) {
            this.blocks[i][j].minesNearBy = computeNearBy(
              this.blocks,
              i,
              j,
              this.n
            );
          } else {
            this.blocks[i][j].minesNearBy = -1;
          }
        }
      }
    }

对应 template 模板:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<div class="columns" v-for="(itemY, Yindex) in blocks" :key="Yindex">
  <div
    class="rowBlocks"
    v-for="(itemX, Xindex) in itemY"
    :key="Xindex"
    :style="this.blocks[Xindex][Yindex].styles"
    @click.right="mark(Xindex, Yindex)"
    @click.left="open(Xindex, Yindex)"
  >
    <span v-show="this.blocks[Xindex][Yindex].shown"
      >{{ this.blocks[Xindex][Yindex].minesNearBy }}</span
    >
    <span v-show="this.blocks[Xindex][Yindex].isMine && this.gg">💣</span>
    <span v-show="this.blocks[Xindex][Yindex].marked && !this.gg">🚩</span>
  </div>
</div>

在这里涉及到了方格的连锁点开,效果如下:

chainOpen

如果点击的格子周围没有炸弹,即格子的minesNearBy值为 0,则把与其相连的minesNearBy值同为 0 的格子也一同打开,并显示外层格子的minesNearBy值,这一功能我将在后续的文章中更新,详见:《深度优先搜索(DFS)在扫雷游戏中的应用》

整个应用大体就是这样,此外细枝末节的东西不多做赘述,顺带实现了一手暗黑模式(

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// 全局配色
@media (prefers-color-scheme: dark) {
  :root {
    --back-ground-color: #000000;
    --base-color: #0a2525;
    --block-font-color: #ffffff;
    --block-color: #476b6b;
  }
}
@media (prefers-color-scheme: light) {
  :root {
    --back-ground-color: #ffffff;
    --base-color: #d1ebeb;
    --block-font-color: #ffffff;
    --block-color: #61a1a1;
  }
}

其实就是在根组件(App.vue)下配置全局配色,然后在其他元素的样式下调用变量而已,效果如下:

暗黑模式

这一个小游戏断断续续花了大概三天写完,感受还是颇深,对 js 和 vue 的理解又更进了一分,接下来写一手 2048,顺带训练一下 CSS。

Built with Hugo
主题 StackJimmy 设计