编程技术文章分享与教程

网站首页 > 技术文章 正文

H5小游戏开发连载教程之扫雷游戏实现「含源码」

hmc789 2024-11-15 19:34:52 技术文章 2 ℃

接上篇教程:H5小游戏开发教程之页面基础布局的开发

很抱歉,让大家久等了,从上周开始,工作很忙,一直没时间写,在这期间,我也在思考是否有更好或更简单的实现方案,在不同的设备上都能有不错的体验;通过这篇教程,我为大家带来一个非常简单的扫雷游戏实现方案;原本打算用两篇文章的,由于过于简单,就用一篇文章搞定了;

我们先欣赏下本篇文章实现的游戏界面:




我想,Windows的扫雷游戏大家应该都玩过吧?其实,这个游戏是有成功诀窍的,它考察了你思考问题的能力;如果1个格子的数值是1,那么它的周围8个方向有且只有一个雷;同理,格子数值是2,它的周围8个方向有且只有2个雷;由于1个格子最多有8个相邻格子,所以1个格子周围最多包含8个雷;

现在,我们正式开始。首先,我们在src根目录创建一个文件:shared.js文件,这个文件用于定义所有游戏公用的变量及函数;我们在该文件中定义一个genArr函数;该函数非常简单,用于创建一个指定长度的数组并用指定的值填充;在我们的游戏教程中,会大量使用该函数生成用于遍历的数组;

export const genArr = (len, v) => Array(len).fill(v)

然后,在src/components/mine文件夹下创建一个文件game.js。我们首先用JS文档注释声明2个类型,并引入一些我们将要用到的函数;通过语义,大家应该能明白这2个类型中字段的含义吧?GameOptions类型中的rows是行数,cols是列数,mineCount是雷的数量;Block类型的num是格子的值,open是打开标识,flag是插旗标识,插旗是用于标记已确定了的雷,以防误点击;

/**
 * @typedef {{rows: number, cols: number, mineCount: number}} GameOptions 游戏选项
 * 
 * @typedef {{num: number, open: boolean, flag: boolean}} Block 方格子
 */

import { computed, reactive } from 'vue'
import { genArr } from '../../shared'

然后,我们导出一个匿名函数。大家切记:我们以下所有的JS代码全部写在该函数内部;

export default () => {}

然后,我们创建一个用于保存游戏状态的响应式对象

const state = reactive({
    rows: 9, // 行数
    cols: 9, // 列数
    mineCount: 10, // 雷数量
    /** @type {Block[][]} 存放格子的二维数组 */
    blocks: [],
    isOver: false, // 游戏结束
    isFirstClick: true // 是否首次点击
  })

然后,我们定义一个根据网格行列数生成二维数组阵列的函数,初始格子的值num全部设为0,open和flag属性都为false;

  /** @returns {Block[][]} */
  const genBlocks = (rows, cols) => {
    return genArr(rows).map(() => genArr(cols).map(() => ({ num: 0, open: false, flag: false })))
  }

然后,我们定义一个获取格子对象的函数,由于我们很多地方需要获取格子对象,所以定义一个函数比较好;

const getBlock = (row, col) => (state.blocks[row] || [])[col]

扫雷游戏有一个原则就是,首次单击的格子不能是地雷,所以,我们必须在玩家首次点开一个格子后,再生成地雷分布图;我们生成地雷分布图的函数需要一个行列坐标,来确保该坐标一定不是地雷。如下是生成地雷分布图函数:

const genMap = (row, col) => {
    const { blocks } = state

    genArr(state.rows)
      .reduce((t, _, i) => [...t, ...genArr(state.cols).map((_, j) => [i, j])], []) // 行列坐标构成的一维数组
      .filter(_ => !(_[0] === row && _[1] === col)) // 过滤掉玩家首次单击的坐标
      .sort(() => Math.random() - .5) // 对坐标随机排序
      .slice(0, state.mineCount) // 根据雷的数量对数组切片
      .forEach(_ => {
        blocks[_[0]][_[1]].num = 9 // 遍历坐标数组,将对应坐标的格子对象的值设置为9,9代表雷
      })
    // 如下遍历用于更新每个非雷的格子周围雷的数量,num的值就是雷的数量
    blocks.forEach((a, i) => {
      a.forEach((b, j) => {
        if (b.num < 9) {
          b.num = [
            getBlock(i - 1, j - 1),
            getBlock(i - 1, j),
            getBlock(i - 1, j + 1),
            getBlock(i, j + 1),
            getBlock(i + 1, j + 1),
            getBlock(i + 1, j),
            getBlock(i + 1, j -1),
            getBlock(i, j - 1)
          ].filter(_ => _ && _.num > 8).length
        }
      })
    })
  }

当玩家点开一个格子后,如果该格子的值是0,那么我们需要深度递归遍历,将相邻的值为0和1的格子全部自动打开;如下是自动打开格子的函数定义:

const openBlocks = (row, col) => {
    ;[
      [row - 1, col],
      [row, col + 1],
      [row + 1, col],
      [row, col - 1]
    ].forEach(coords => {
      const block = getBlock(...coords) // es6参数展开
      if (block && !block.open && !block.flag) { // 如果格子存在并且没被打开且没被插旗
        if (block.num < 2) { // 如果格子值为0和1,将格子打开
          block.open = true
        }
        if (block.num < 1) { // 如果值为0,进行深度递归遍历
          openBlocks(...coords)
        }
      }
    })
  }

当玩家点击的格子值为9时,我们需要打开所有的地雷,并结束游戏;如下是自动打开所有地雷的函数:

const openMineBlocks = () => {
    state.blocks.forEach(a => {
      a.forEach(b => {
        if (b.num > 8) {
          b.open = true
        }
      })
    })
  }

当玩家打开了所有不是雷的格子后,我们需要结束游戏,如下是用于判断是否已完成挑战的函数:

const isFinish = () => {
    return state.blocks.every(a => a.filter(b => b.num < 9).every(b => b.open))
  }

我们需要一个函数,用于开始新游戏,该函数用于对游戏状态进行重置或更新,并启动游戏;如下是开始游戏函数定义:

/** @param {GameOptions} options */
  const start = (options = {}) => {
    Object.keys(options).forEach(key => {
      if (options[key]) {
        state[key] = options[key]
      }
    })
    state.isOver = false
    state.isFirstClick = true
    state.blocks = genBlocks(state.rows, state.cols)
  }

我们需要一个用于处理格子单击事件的函数。

const onBlockClick = (row, col) => {
    const block = getBlock(row, col)
    if (state.isOver || block.flag || block.open) return // 如果游戏结束或格子插了旗或格子已打开,直接返回
    block.open = true
    if (state.isFirstClick) { // 如果是首次单击格子,那么生成地雷分布图
      state.isFirstClick = false
      genMap(row, col)
    }
    if (block.num > 8) { // 如果该格子是地雷,结束游戏并自动炸开所有的地雷
      state.isOver = true
      openMineBlocks()
      return setTimeout(() => confirm('挑战失败!是否重新开始?') && start(), 100)
    } else if (block.num < 1) {
      openBlocks(row, col) // 使用深度递归遍历,打开值为0和1的相邻格子
    }
    // 如果挑战成功,那么给玩家2个选择:挑战更高难度或重新挑战该难度
    isFinish() && setTimeout(() => confirm('挑战成功!是否挑战更高难度?')
      ? start({ rows: state.rows + 1, mineCount: state.mineCount + state.cols })
      : start(), 100)
  }

我们需要一个用于处理格子上下文菜单的函数,该函数用于插旗和移除旗之间切换。

/** @param {PointerEvent} evt */
  const onBlockContextmenu = (row, col, evt) => {
    evt.preventDefault()
    if (state.isOver) return
    const block = getBlock(row, col)
    if (!block.open) {
      block.flag = !block.flag
    }
  }

如下是3个计算属性定义,分别用于统计插旗数量,打开的格子数量,未打开的格子数量。

const flagCount = computed(() => {
    return state.blocks.reduce((t, a) => t + a.filter(_ => _.flag).length, 0)
  })

  const openCount = computed(() => {
    return state.blocks.reduce((t, a) => t + a.filter(_ => _.open).length, 0)
  })

  const unopenCount = computed(() => state.rows * state.cols - openCount.value)

最后,我们在导出的匿名函数的底部返回组件中使用到的变量和函数。

return { state, flagCount, openCount, unopenCount, start, onBlockClick, onBlockContextmenu }

如下是src/components/mine/Index.vue文件源码,我们使用table承载游戏界面;我认为table很适合二维数组数据的可视化;

<template>
  <div :class="cls">
    <header :class="`${cls}_header`">
      <ul>
        <li>
          网格布局:<b class="blue">{{ state.rows }}×{{ state.cols }}</b>
        </li>
        <li>
          雷数量:<b class="red">{{ state.mineCount }}</b>
        </li>
        <li>
          插旗数量:<b class="green">{{ flagCount }}</b>
        </li>
        <li>
          已打开数量:<b class="green">{{ openCount }}</b>
        </li>
        <li>
          未打开数量:<b class="blue">{{ unopenCount }}</b>
        </li>
      </ul>
      <button class="btn" @click="start">新游戏</button>
    </header>
    <table :class="`${cls}_table`">
      <tr v-for="(a, i) in state.blocks" :key="i">
        <td v-for="(b, j) in a" :key="j">
          <div v-if="b.open" :class="`${cls}_block is-open`">
            <img class="back" src="./img/back.png">
            <span v-if="b.num > 8" :class="`${cls}_mine`"></span>
            <span v-else-if="b.num > 0" :class="[`${cls}_num`, getNumCls(b.num)]">{{ b.num }}</span>
          </div>
          <div v-else :class="`${cls}_block`" @click="onBlockClick(i, j)" @contextmenu="onBlockContextmenu(i, j, $event)">
            <img class="back" src="./img/front.png">
            <span v-if="b.flag" :class="`${cls}_flag`"></span>
          </div>
        </td>
      </tr>
    </table>
  </div>
</template>

<script setup>
import useGame from './game'
const { state, flagCount, openCount, unopenCount, start, onBlockClick, onBlockContextmenu } = useGame()
start()
const cls = 'com-mine'
const getNumCls = num => num > 3 ? 'red' : num > 2 ? 'yellow' : num > 1 ? 'blue' : 'white'
</script>

<style lang="less">
.com-mine {
  &_header {
    background-color: #eceff1;
    margin-bottom: .5em;
    border-radius: 4px;
    padding: .8em;
    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2), 0 1px 1px rgba(0, 0, 0, 0.14), 0 2px 1px -1px rgba(0, 0, 0, 0.12);
    display: flex;
    align-items: flex-end;
    ul {
      flex: 1;
      width: 0;
      li {
        display: inline-block;
        margin: .3em 1em .3em 0;
      }
    }
  }
  &_block {
    position: relative;
    &:not(.is-open) {
      cursor: pointer;
    }
    .back {
      display: block;
      width: 100%;
    }
  }
  &_num, &_flag, &_mine {
    position: absolute;
    top: 0;
    right: 0;
    bottom: 0;
    left: 0;
  }
  &_num {
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 16px;
  }
  &_flag {
    background: url("./img/flag-color.png") no-repeat center center;
    background-size: 60%;
  }
  &_mine {
    background: url("./img/bomb-color.png") no-repeat center center;
    background-size: 30%;
  }
  table {
    border-radius: 4px;
    padding: 1px;
    background-color: #b0bec5;
  }
}
</style>

如下是本篇教程中用到的几张图片,由于是在网上找的,担心有版权问题,仅提供截图,就不放到文章里面了,大家可以用自己的图片替代,将图片放到src/components/mine/img文件夹中;其实这个不是重点,重点是实现原理。

感谢阅读!以上就是本篇教程的全部内容,童鞋们都理解了吗?我感觉扫雷游戏的实现非常简单,几乎没什么难度,童鞋们应该都能理解吧?如果还有疑问,可以问我;

上一篇:H5小游戏开发教程之页面基础布局的开发

标签列表
最新留言