Skip to content

经典连连看

玩法

经典扫雷,就是 windows 自带的扫雷游戏,玩法一致,包括 右键插旗 🚩 双击开周边 等功能。

重要提示

受浏览器限制,有些浏览器(360浏览器等)默认开启 右键手势 功能,可能会影响双击判断,需要关闭此类功能后才能保证双击完美判断。

算法

摆放地雷

这个比较简单:

  1. 现根据地雷数量进行随机摆放;
  2. 填充剩余格式的 数值空格

随机填充地雷基础算法:

ts
import { getRandomInteger } from "./number.util";

export const getRandomInArray = (list = [], n = 1) => {
  if (!list.length) return [];
  const arr = [...list],
    res = [];
  while (n-- > 0) {
    const r = Math.floor(Math.random() * arr.length);
    res.push(arr.splice(r, 1)[0]);
  }
  return res;
};

export const getRandomRangeInArray = (list = [], len = 1, extend = false): any[] => {
  if (!list.length) return [];
  if (!extend) len = Math.min(len, list.length);
  const ans = [],
    offset = getRandomInteger(0, list.length - 1);
  for (let i = 0; i < len; i++) {
    ans.push(list[(i + offset) % list.length]);
  }
  return ans;
};

// 旧版实现方法

export const randomRange = (start, end) => {
  // @ts-ignore
  return Number.parseInt(Math.random() * (end - start + 1));
};

// @ts-ignore
Array.prototype.getRandom = function (n = null) {
  if (n === null) n = this.length;
  let arr = [...this],
    res = [];
  while (n-- > 0) {
    const r = randomRange(0, arr.length - 1);
    res.push(arr.splice(r, 1)[0]);
  }
  return res;
};

// @ts-ignore
Array.prototype.randomSort = function () {
  const arr = this.getRandom();
  for (let i = 0; i < this.length; i++) {
    this[i] = arr[i];
  }
  return this;
};

// @ts-ignore
Array.prototype.getRandomSlice = function (n = null) {
  if (n === null) n = this.length;
  const arr = [];
  const r = randomRange(0, this.length - 1);
  for (let i = 0; i < n; i++) {
    arr.push(this[(r + i) % this.length]);
  }
  return arr;
};

// @ts-ignore
Array.prototype.createNatureNumber = function (num, start = 1) {
  return new Array(num).fill(null).map((x, i) => i + start);
};

// @ts-ignore
Array.prototype.isSame = function (arr = []) {
  if (this.length !== arr.length) return false;
  const s = new Set(this.concat(arr));
  return s.size === this.length && s.size === arr.length;
};

// @ts-ignore
Array.prototype.eachOneByOne = function (arr, cb = null, self = true) {
  const res = self ? this : [];
  for (let i = 0; i < this.length; i++) {
    res[i] = this[i];
    if (arr.length <= i) continue;
    if (cb) {
      res[i] = cb(this[i], arr[i]);
    }
  }
  return res;
};

// @ts-ignore
Number.prototype.floor = function () {
  return Math.floor(this);
};

// @ts-ignore
Number.prototype.round = function () {
  return Math.round(this);
};

// @ts-ignore
Number.prototype.ceil = function () {
  return Math.ceil(this);
};

扩散算法

基于 广度优先搜索 算法;

为了实现动画扩散(每一帧扩散一圈),需要在 广度优先搜索 中加入延时扩散;

延时扩散部分源码:

ts
export const getRandomInArray = (list = [], n = 1) => {
  if (!list.length) return [];
  const arr = [...list],
    res = [];
  while (n-- > 0) {
    const r = Math.floor(Math.random() * arr.length);
    res.push(arr.splice(r, 1)[0]);
  }
  return res;
};

export const getRandomRangeInArray = (list = [], len = 1, extend = false): any[] => {
  if (!list.length) return [];
  if (!extend) len = Math.min(len, list.length);
  const ans = [],
    offset = getRandomInteger(0, list.length - 1);
  for (let i = 0; i < len; i++) {
    ans.push(list[(i + offset) % list.length]);
  }
  return ans;
};

源码

vue
<template>
  <div class="mine-sweeping vue-component-container flex-column flex-center-all">
    <div class="mb-md flex-column flex-center-all">
      <div class="flex-horiz">
        <a-button type="primary" v-if="gameState !== 2" @click="restart">开始游戏</a-button>
        <a-button type="error" v-else @click="gameOver(false)">停止游戏</a-button>
        <div class="ml-xs">
          <a-select
            style="width: 100px"
            :options="levels"
            v-model="nowLevelValue"
            :disabled="gameState === 2"
          ></a-select>
        </div>
      </div>

      <div class="flex-horiz mt-sm" v-if="nowLevelValue === 4">
        <div class="ml-xs">
          <a-input-group>
            <a-input-number
              :style="{ width: '100px' }"
              v-model="nowLevel.row"
              :disabled="gameState === 2"
            >
              <template #prefix> 行 </template>
            </a-input-number>
          </a-input-group>
        </div>
        <div class="ml-xs">
          <a-input-group>
            <a-input-number
              :style="{ width: '100px' }"
              v-model="nowLevel.col"
              :disabled="gameState === 2"
            >
              <template #prefix> 列 </template>
            </a-input-number>
          </a-input-group>
        </div>
        <div class="ml-xs">
          <a-input-group>
            <a-input-number
              :style="{ width: '100px' }"
              v-model="nowLevel.boom"
              :disabled="gameState === 2"
            >
              <template #prefix> 雷 </template>
            </a-input-number>
          </a-input-group>
        </div>
      </div>
    </div>

    <div class="flex-horiz flex-center-all mb-xs" v-if="gameState !== 1">
      <div class="">
        <a-tag v-if="gameState === 1">未开始</a-tag>
        <a-tag type="info" v-else-if="gameState === 2">游戏中</a-tag>
        <a-tag type="success" v-else-if="gameState === 3">胜利!</a-tag>
        <a-tag type="error" v-else>失败!</a-tag>
      </div>
      <div class="ml-xs">
        <a-input-group>
          <a-input
            :style="{ width: '100px' }"
            :readonly="true"
            :model-value="`${flagCount} / ${nowLevel.boom}`"
          >
            <template #prefix>雷</template>
          </a-input>
        </a-input-group>
      </div>
      <div class="ml-xs">
        <a-input-group>
          <a-input
            :style="{ width: '100px' }"
            :readonly="true"
            :model-value="`${openCount} / ${nowLevel.row * nowLevel.col}`"
          >
            <template #prefix>扫</template>
          </a-input>
        </a-input-group>
      </div>
      <div class="ml-xs">
        <a-tag type="info" style="width: 80px; text-align: center">{{ timeDisplay }}</a-tag>
      </div>
    </div>
    <div class="grid-container flex-column" onselectstart="return false" @contextmenu.prevent="">
      <div class="flex-horiz" v-for="row in map">
        <div
          class="grid flex-horiz flex-center-all"
          v-for="grid in row"
          :class="gridClass(grid)"
          @contextmenu.prevent=""
          @mousedown.prevent.stop="onMouseDown($event, grid)"
          @mouseup.prevent.stop="onMouseUp($event, grid)"
        >
          <div class="" v-show="grid.status === 1 || grid.status === 3">
            <i class="iconfont icon-bug-fill" v-if="grid.isBoom"></i>
            <span
              v-else-if="grid.number > 0"
              class="grid-number"
              :style="{ color: NumberColors[grid.number - 1] }"
              >{{ grid.number }}</span
            >
            <i v-else class="iconfont icon-checkbox-blank-line color-border-light"></i>
          </div>
          <i v-show="grid.status === 2" class="iconfont icon-flag--fill" style="color: #960000"></i>
        </div>
      </div>
    </div>
  </div>
</template>

<script lang="ts">
import { defineComponent, ref, watch } from "vue";
import { getRandomInArray } from "../../utils/array.util";

class Grid {
  constructor(row, col, number = 0) {
    this.row = row;
    this.col = col;
    this.number = 0;
  }

  number = 0;
  isBoom = false;
  // 0 未打开 , 1 被打开, 2 插旗, 3 中招点
  status = 0;
  row = 0;
  col = 0;
  focusRound = false;
}

const NumberColors = [
  "#0000FF",
  "#008000",
  "#FF0000",
  "#000080",
  "#800000",
  "#008080",
  "#000000",
  "#808080",
];
const Dirs = [
  [0, 1],
  [0, -1],
  [1, 0],
  [-1, 0],
  [-1, -1],
  [-1, 1],
  [1, 1],
  [1, -1],
];

export default defineComponent({
  name: "mine-sweeping",
  components: {},
  props: {},
  setup: function (props: any) {
    const mouseDown = [false, false, false],
      mouseUpTime = [0, 0, 0];
    const openCount = ref(0),
      flagCount = ref(0);
    const levels = [
      { label: "简单", value: 1, row: 9, col: 9, boom: 10 },
      { label: "正常", value: 2, row: 16, col: 16, boom: 40 },
      { label: "困难", value: 3, row: 16, col: 30, boom: 99 },
      { label: "自定义", value: 4, row: 20, col: 20, boom: 50 },
    ];
    const nowLevelValue = ref(2);
    const nowLevel = ref(levels.find(l => l.value === nowLevelValue.value));
    // 1 准备开始, 2 游戏中, 3 赢了  4 输了
    const gameState = ref(1);

    watch(nowLevelValue, v => {
      gameOver(false);
      nowLevel.value = levels.find(l => l.value === v);
    });

    const createNewMap = () => {
      const { row, col, boom } = nowLevel.value;
      const map = Array(row)
        .fill("")
        .map((r, row) =>
          Array(col)
            .fill("")
            .map((c, col) => new Grid(row, col))
        );
      getRandomInArray(
        Array(row * col)
          .fill("")
          .map((x, i) => [Math.floor(i / col), i % col]),
        boom
      ).forEach(([r, c]) => (map[r][c].isBoom = true));
      return map;
    };

    const map = ref(createNewMap());

    const restart = async () => {
      gameState.value = 2;
      openCount.value = 0;
      flagCount.value = 0;
      map.value = createNewMap();
      map.value.forEach(row =>
        row.forEach(g => {
          if (g.isBoom) return;
          forEachAround(g, grid => (g.number += grid.isBoom ? 1 : 0));
        })
      );
    };

    const forEachAround = (grid: Grid, callback) => {
      const { row, col } = grid;
      for (let i = 0; i < Dirs.length; i++) {
        const [dr, dc] = Dirs[i];
        const nr = row + dr,
          nc = col + dc;
        if (inBoundary(nr, nc)) callback && callback(map.value[nr][nc]);
      }
    };

    const inBoundary = (row, col) => {
      const { row: mRow, col: mCol } = nowLevel.value;
      return row >= 0 && row < mRow && col >= 0 && col < mCol;
    };

    const gameOver = (grid = undefined) => {
      map.value.forEach(row => row.forEach(grid => (grid.status = grid.status || 1)));
      if (grid === undefined) return (gameState.value = 3);
      gameState.value = 4;
      if (grid) grid.status = 3;
    };

    // #region delay-spread
    const openMine = grids => {
      const pushed = new Set<Grid>();
      for (const g of grids) {
        if (g.status !== 0) continue;
        g.status = 1;
        openCount.value++;
        if (g.isBoom) return gameOver(g);
        if (openCount.value + nowLevel.value.boom === nowLevel.value.row * nowLevel.value.col)
          return gameOver();
        if (g.number > 0) continue;
        forEachAround(g, grid => {
          if (grid.status !== 0) return;
          if (pushed.has(grid)) return;
          pushed.add(grid);
        });
      }
      setTimeout(openMine.bind(this, pushed), 20);
    };
    // #endregion delay-spread

    const leftClick = (grid: Grid) => {
      openMine([grid]);
      startTimer();
    };

    const rightClick = (grid: Grid) => {
      if (grid.status === 0) {
        grid.status = 2;
        flagCount.value++;
      } else if (grid.status === 2) {
        grid.status = 0;
        flagCount.value--;
      }
    };

    const doubleClick = (grid: Grid) => {
      if (grid.status !== 1 && grid.number === 0) return;
      let flagCount = 0,
        grids = [];
      forEachAround(grid, g => {
        flagCount += g.status === 2 ? 1 : 0;
        if (g.status === 0) grids.push(g);
      });
      if (flagCount === grid.number) openMine(grids);
    };

    const doubleDown = grid => {
      forEachAround(grid, g => {
        g.focusRound = mouseDown[0] && mouseDown[2];
      });
    };

    let timer,
      startTime = 0;
    const timeDisplay = ref("0.000".padStart(8, "0"));
    const startTimer = () => {
      clearTimeout(timer);
      if (gameState.value !== 2) {
        startTime = 0;
        return;
      }
      if (startTime === 0) startTime = new Date().getTime();
      const t = (new Date().getTime() - startTime) / 1000;
      timeDisplay.value = t.toFixed(3).padStart(8, "0");
      timer = setTimeout(startTimer, 1);
    };

    return {
      map,
      NumberColors,
      gameState,
      levels,
      nowLevelValue,
      nowLevel,
      openCount,
      flagCount,
      timeDisplay,

      restart,
      gameOver,
      onMouseUp: (e, grid) => {
        if (gameState.value !== 2) return;
        mouseDown[e.button] = false;
        doubleDown(grid);
        mouseUpTime[e.button] = new Date().getTime();
        if (mouseDown[0] || mouseDown[2]) return;
        if (Math.abs(mouseUpTime[0] - mouseUpTime[2]) < 100) return doubleClick(grid);
        if (e.button === 0) return leftClick(grid);
        if (e.button === 2) return rightClick(grid);
      },
      onMouseDown: (e, grid) => {
        if (gameState.value !== 2) return;
        mouseDown[e.button] = true;
        doubleDown(grid);
      },
      gridClass: grid => [
        grid.status === 3 ? "game-over" : "",
        grid.focusRound && grid.status === 0 ? "focus" : "",
        grid.status === 1 ? "active" : "",
      ],
    };
  },
});
</script>

<style lang="less" scoped>
.mine-sweeping {
}

.grid-container {
  border: 3px solid #cccccc;
  padding: 1.5px;
  background-color: #fff;
}

.grid {
  .grid-number {
    font-family: "Fixedsys", "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB",
      "Microsoft YaHei", "微软雅黑", Arial, sans-serif !important;
  }

  height: 25px;
  width: 25px;
  margin: 0.5px;
  color: #222222;
  font-weight: bold;
  font-size: 18px;

  &.active {
    border: 1px solid #eaeaea;
  }

  &:not(.active) {
    border: 1px solid #e8e8e8;
    background-color: #ececec;
  }

  &.game-over {
    background-color: #e85656;
  }

  &.focus {
    background-color: #cecece;
  }
}
</style>

MIT Licensed | fangjc1986@qq.com